feat: add feature toggles settings (#1372)

This commit is contained in:
Carlos Florêncio
2026-06-15 13:33:45 +01:00
committed by GitHub
parent 3a3fca7f0e
commit fd421848fb
74 changed files with 4096 additions and 256 deletions
+3 -1
View File
@@ -88,9 +88,11 @@ Jira and Linear are the model (per-workspace credentials, 90s auth-health poller
**`profiles.yaml` at the repo root** is the single source of truth for env-driven runtime defaults — feature flags, mock providers (agent / GitHub / Jira / Linear), debug switches, and e2e tuning knobs. The backend embeds it (`//go:embed` via `apps/backend/internal/profiles/`) and at startup calls `profiles.ApplyProfile()` to write the matching profile's env vars onto its own process, *only when each var is not already set* — so launchers, shells, and per-spec overrides still win.
Runtime feature toggles add a SQLite-backed override tier managed through `Settings > System > Feature Toggles`. Effective values use this precedence: explicit environment variable > SQLite override > profile default. The typed runtime flag registry lives in `apps/backend/internal/runtimeflags/registry.go`; add or update registry metadata when exposing a flag in the UI.
Profile selection: `KANDEV_E2E_MOCK=true` → `e2e`, `KANDEV_DEBUG_DEV_MODE=true` → `dev`, otherwise `prod`. `apps/cli/src/dev.ts` and `apps/web/e2e/fixtures/backend.ts` set only the selector — they no longer hardcode the underlying values.
To flip a feature on for every user: change its `prod:` to `"true"` in `profiles.yaml`. To add a new feature flag: 1 line in `profiles.yaml` + 1 `FeaturesConfig` field + the gate at the call site + the frontend additions (`FeatureFlags` type, `useFeature` checks, `notFound()` from a server-side layout). Full pattern in `docs/decisions/0007-runtime-feature-flags.md`.
To flip a feature on for every user: change its `prod:` to `"true"` in `profiles.yaml`. To add a new feature flag: 1 line in `profiles.yaml` + 1 `FeaturesConfig` field + the runtime flag registry entry when it should be user-toggleable + the gate at the call site + the frontend additions (`FeatureFlags` type, `useFeature` checks, `notFound()` from a server-side layout). Full pattern in `docs/decisions/0007-runtime-feature-flags.md`; runtime overrides and restart support are documented in `docs/decisions/0018-runtime-settings-overrides.md` and `docs/decisions/0019-restart-supervisor.md`.
---
+3
View File
@@ -79,6 +79,9 @@ func handleE2EReset(
log.Warn("e2e reset: routing cleanup failed", zap.String("sql", q), zap.Error(err))
}
}
if _, err := repo.DB().ExecContext(ctx, `DELETE FROM runtime_flag_overrides`); err != nil {
log.Warn("e2e reset: runtime flag override cleanup failed", zap.Error(err))
}
// Reset every agent's routing override to the inherit-markers
// shape onboarding writes. Without this, an agent-override test
+5
View File
@@ -58,6 +58,7 @@ import (
promptcontroller "github.com/kandev/kandev/internal/prompts/controller"
prompthandlers "github.com/kandev/kandev/internal/prompts/handlers"
"github.com/kandev/kandev/internal/repoclone"
"github.com/kandev/kandev/internal/runtimeflags"
"github.com/kandev/kandev/internal/secrets"
"github.com/kandev/kandev/internal/sentry"
"github.com/kandev/kandev/internal/slack"
@@ -435,6 +436,7 @@ type routeParams struct {
eventBus bus.EventBus
services *Services
systemSvc *systemsvc.Service
runtimeFlagsSvc *runtimeflags.Service
agentSettingsController *agentsettingscontroller.Controller
agentSettingsRepo settingsstore.Repository
agentList taskhandlers.AgentLister
@@ -785,6 +787,9 @@ func registerSecondaryRoutes(
registerHealthRoutes(p)
registerSystemRoutes(p)
if p.runtimeFlagsSvc != nil {
runtimeflags.RegisterRoutes(p.router, p.runtimeFlagsSvc)
}
if p.repoCloner != nil {
ikHandler := improvekandev.NewHandler(p.taskSvc, p.repoCloner, p.version, p.log)
+25
View File
@@ -102,6 +102,7 @@ import (
// Repository cloning
"github.com/kandev/kandev/internal/repoclone"
"github.com/kandev/kandev/internal/runtimeflags"
// Secrets
"github.com/kandev/kandev/internal/secrets"
@@ -255,6 +256,20 @@ func run(cfg *config.Config, log *logger.Logger, cleanups *[]func() error, runCl
return startServices(ctx, cfg, log, addCleanup, eventBus, runCleanups)
}
func applyStartupRuntimeFlags(ctx context.Context, cfg *config.Config, repos *Repositories, log *logger.Logger) bool {
if repos.RuntimeFlags == nil {
return true
}
svc := runtimeflags.NewService(repos.RuntimeFlags, runtimeflags.OptionsFromConfig(cfg))
states, err := svc.ListStates(ctx)
if err != nil {
log.Error("Failed to resolve runtime flag overrides", zap.Error(err))
return false
}
runtimeflags.ApplyStatesToConfig(cfg, states)
return true
}
// startServices initializes task-level services and all downstream infrastructure.
func startServices( //nolint:cyclop
ctx context.Context,
@@ -278,6 +293,11 @@ func startServices( //nolint:cyclop
addCleanup(c)
}
runtimeFlagDefaults := runtimeflags.OptionsFromConfig(cfg).DefaultValues
if !applyStartupRuntimeFlags(ctx, cfg, repos, log) {
return false
}
agentRegistry, _, err := registry.Provide(log)
if err != nil {
log.Error("Failed to initialize agent registry", zap.Error(err))
@@ -289,6 +309,10 @@ func startServices( //nolint:cyclop
log.Error("Failed to initialize services", zap.Error(err))
return false
}
services.RuntimeFlags = runtimeflags.NewService(
repos.RuntimeFlags,
runtimeflags.RuntimeOptionsFromAppliedConfig(runtimeFlagDefaults, cfg),
)
log.Info("Task Service initialized")
if err := runInitialAgentSetup(ctx, services.User, agentSettingsController, log); err != nil {
@@ -1542,6 +1566,7 @@ func buildHTTPServer(
eventBus: eventBus,
services: services,
systemSvc: systemSvc,
runtimeFlagsSvc: services.RuntimeFlags,
agentSettingsController: agentSettingsController,
agentSettingsRepo: repos.AgentSettings,
agentList: agentRegistry,
+7
View File
@@ -22,6 +22,7 @@ import (
notificationstore "github.com/kandev/kandev/internal/notifications/store"
"github.com/kandev/kandev/internal/office"
promptstore "github.com/kandev/kandev/internal/prompts/store"
"github.com/kandev/kandev/internal/runtimeflags"
userstore "github.com/kandev/kandev/internal/user/store"
)
@@ -78,6 +79,11 @@ func provideRepositories(cfg *config.Config, log *logger.Logger, version string)
return nil, nil, nil, fmt.Errorf("terminal repo: %w", err)
}
runtimeFlagsStore, err := runtimeflags.NewSQLiteStore(writer, reader)
if err != nil {
return nil, nil, nil, fmt.Errorf("runtime flags store: %w", err)
}
masterKeyProvider, err := secrets.NewMasterKeyProvider(cfg.ResolvedDataDir())
if err != nil {
return nil, nil, nil, fmt.Errorf("master key: %w", err)
@@ -105,6 +111,7 @@ func provideRepositories(cfg *config.Config, log *logger.Logger, version string)
Secrets: secretStore,
Office: officeRepo,
Terminal: terminalRepoImpl,
RuntimeFlags: runtimeFlagsStore,
}
return pool, repos, cleanups, nil
}
+4 -1
View File
@@ -17,6 +17,7 @@ import (
officeservice "github.com/kandev/kandev/internal/office/service"
promptservice "github.com/kandev/kandev/internal/prompts/service"
promptstore "github.com/kandev/kandev/internal/prompts/store"
"github.com/kandev/kandev/internal/runtimeflags"
"github.com/kandev/kandev/internal/secrets"
"github.com/kandev/kandev/internal/sentry"
"github.com/kandev/kandev/internal/slack"
@@ -47,6 +48,7 @@ type Repositories struct {
Secrets secrets.SecretStore
Office *officesqlite.Repository
Terminal *terminalrepo.Repository
RuntimeFlags *runtimeflags.SQLiteStore
}
type Services struct {
@@ -76,7 +78,8 @@ type Services struct {
// Terminal is the first-class user-terminal service (rename, park, etc.).
// Wired into the gateway once lifecycle.Manager is up so the PTY backend
// is available.
Terminal *terminalservice.Service
Terminal *terminalservice.Service
RuntimeFlags *runtimeflags.Service
// Automation is the trigger-based automation subsystem (cron, GitHub PR
// events, webhooks). Independent of Office — has its own scheduler and
// creates tasks via the task service.
+21
View File
@@ -14,6 +14,7 @@ import (
// appsDir is the apps/ workspace root relative to the working directory (apps/backend/).
const appsDir = ".."
const repoRootDir = "../.."
// goDockerImage is used to cross-compile CGO binaries on non-linux/amd64 hosts.
const goDockerImage = "golang:1.26-bookworm"
@@ -139,12 +140,27 @@ func buildWeb(ctx context.Context, skipInstall bool) error {
return nil
}
// buildCLI creates the bundled TypeScript launcher used by release-style
// installs. Preview sprites run through this launcher so their backend is
// managed by the same restart supervisor as normal kandev starts.
func buildCLI(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "bash", "scripts/release/package-cli.sh")
cmd.Dir = repoRootDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("package cli: %w", err)
}
return nil
}
// packageBundle creates a tar.gz matching the Docker container layout:
//
// app/apps/backend/bin/{kandev,agentctl,mock-agent}
// app/apps/web/.next/standalone/ (Next.js server + node_modules)
// app/apps/web/.next/standalone/web/.next/static/ (static assets inside standalone)
// app/apps/web/.next/standalone/web/public/ (public assets inside standalone)
// usr/local/lib/kandev-cli/ (CLI launcher bundle)
func packageBundle(binDir, tarPath string) error {
f, err := os.Create(tarPath)
if err != nil {
@@ -188,6 +204,11 @@ func packageBundle(binDir, tarPath string) error {
return fmt.Errorf("add public: %w", err)
}
cliDir := filepath.Join(repoRootDir, "dist", "kandev", "cli")
if err := addDirToTar(tw, cliDir, filepath.Join("usr", "local", "lib", "kandev-cli")); err != nil {
return fmt.Errorf("add cli: %w", err)
}
// Close in order: tar → gzip → file (flush compressed data).
if err := tw.Close(); err != nil {
return fmt.Errorf("close tar: %w", err)
+5
View File
@@ -85,6 +85,11 @@ func deployArtifacts(ctx context.Context, binDir, tarPath, spritesToken, spriteN
return "", fmt.Errorf("build web: %w", err)
}
fmt.Fprintln(os.Stderr, "building cli launcher...")
if err := buildCLI(ctx); err != nil {
return "", fmt.Errorf("build cli: %w", err)
}
fmt.Fprintln(os.Stderr, "packaging bundle...")
if err := packageBundle(binDir, tarPath); err != nil {
return "", fmt.Errorf("package bundle: %w", err)
+15 -13
View File
@@ -104,9 +104,11 @@ func buildExtractScript(backendPort int) string {
tar -xzf /tmp/kandev-preview.tar.gz -C /
chmod +x /app/apps/backend/bin/kandev \
/app/apps/backend/bin/agentctl \
/app/apps/backend/bin/mock-agent
/app/apps/backend/bin/mock-agent \
/usr/local/lib/kandev-cli/bin/cli.js
ln -sf /app/apps/backend/bin/agentctl /usr/local/bin/agentctl
ln -sf /app/apps/backend/bin/mock-agent /usr/local/bin/mock-agent
ln -sf /usr/local/lib/kandev-cli/bin/cli.js /usr/local/bin/kandev
# Reset data directory on each deploy so the DB starts fresh (preview env only).
rm -rf /data
mkdir -p /data /var/log
@@ -120,27 +122,27 @@ mkdir -p /data
# Kill any agentctl orphans from previous runs.
pkill -f agentctl || true
# Kill any stale web (node) process — nohup'd node survives StopService since
# it detaches from the process group, and would hold the web port on redeploy.
# Kill any stale web (node) process from older preview deployments, where the
# web server was nohup'd outside the service process group.
pkill -f '/app/apps/web/.next/standalone/web/server.js' || true
sleep 1
cd /app
# Start Next.js web server in background.
PORT=%d HOSTNAME=0.0.0.0 NODE_ENV=production \
nohup node /app/apps/web/.next/standalone/web/server.js \
> /var/log/kandev-web.log 2>&1 &
# Start Go backend (main process — Sprites HTTPPort proxies here).
export KANDEV_HOME_DIR=/data
export KANDEV_DOCKER_ENABLED=false
export KANDEV_LOG_LEVEL=info
export KANDEV_SERVER_PORT=%d
export KANDEV_WEB_INTERNAL_URL=http://localhost:%d
# Preview mode: only register the mock agent, suppress real agent discovery.
export KANDEV_MOCK_AGENT=only
/app/apps/backend/bin/kandev > /var/log/kandev.log 2>&1
# Launch through the CLI so the backend runs under the restart supervisor.
exec kandev start \
--backend-port %d \
--web-internal-port %d \
--verbose \
--headless \
> /var/log/kandev.log 2>&1
STARTSCRIPT
chmod +x /app/start-kandev.sh`, ports.Web, backendPort, ports.Web)
chmod +x /app/start-kandev.sh`, backendPort, ports.Web)
}
// deployService stops any running kandev service, registers (or updates) its
+17 -2
View File
@@ -8,8 +8,8 @@ import (
func TestBuildExtractScript(t *testing.T) {
script := buildExtractScript(12345)
if !strings.Contains(script, "KANDEV_SERVER_PORT=12345") {
t.Errorf("expected KANDEV_SERVER_PORT=12345 in script, got:\n%s", script)
if !strings.Contains(script, "--backend-port 12345") {
t.Errorf("expected --backend-port 12345 in script, got:\n%s", script)
}
if !strings.Contains(script, "rm -rf /data") {
t.Errorf("expected rm -rf /data in script")
@@ -17,4 +17,19 @@ func TestBuildExtractScript(t *testing.T) {
if !strings.Contains(script, "KANDEV_MOCK_AGENT=only") {
t.Errorf("expected KANDEV_MOCK_AGENT=only in script")
}
if !strings.Contains(script, "ln -sf /usr/local/lib/kandev-cli/bin/cli.js /usr/local/bin/kandev") {
t.Errorf("expected kandev cli symlink in script")
}
if !strings.Contains(script, "exec kandev start") {
t.Errorf("expected script to launch through kandev start")
}
if !strings.Contains(script, "--headless") {
t.Errorf("expected headless CLI launch")
}
if strings.Contains(script, "nohup node") {
t.Errorf("script should not start web outside the CLI supervisor")
}
if strings.Contains(script, "/app/apps/backend/bin/kandev >") {
t.Errorf("script should not launch the backend binary directly")
}
}
@@ -214,14 +214,14 @@ func TestClient_FirstBootMissesGracefully(t *testing.T) {
// configured URL. Block that refresh at the server so it can't
// populate the cache before the lookup reads it — otherwise the
// "miss" we're asserting races a fast background fetch and flakes
// into a hit. The handler unblocks at cleanup so nothing leaks.
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
<-release
_, _ = w.Write([]byte(sampleDataset))
// into a hit. The request exits when the lookup context is canceled,
// before TempDir cleanup can race a cache write.
ctx, cancel := context.WithCancel(context.Background())
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}))
t.Cleanup(func() {
close(release)
cancel()
srv.Close()
})
c := modelsdev.New(modelsdev.Config{
@@ -232,7 +232,7 @@ func TestClient_FirstBootMissesGracefully(t *testing.T) {
}, logger.Default())
// No Refresh — simulating cold boot before any HTTP fetch.
if _, ok := c.LookupForModel(context.Background(), "claude-opus-4-7"); ok {
if _, ok := c.LookupForModel(ctx, "claude-opus-4-7"); ok {
t.Error("expected miss on cold-boot lookup")
}
}
@@ -33,6 +33,7 @@ import (
"fmt"
"os"
"sort"
"sync"
"gopkg.in/yaml.v3"
)
@@ -40,6 +41,16 @@ import (
//go:embed profiles.yaml
var profilesYAML []byte
var appliedEnvVars = struct {
sync.RWMutex
names map[string]bool
}{names: map[string]bool{}}
var derivedAppliedEnvVars = map[string]bool{
"KANDEV_DEBUG_AGENT_MESSAGES": true,
"KANDEV_DEBUG_PPROF_ENABLED": true,
}
// Environment identifies the active runtime profile.
type Environment string
@@ -103,12 +114,37 @@ func ApplyProfile() (count int, env Environment, err error) {
if err := os.Setenv(name, value); err != nil {
return count, env, fmt.Errorf("setenv %q: %w", name, err)
}
appliedEnvVars.Lock()
appliedEnvVars.names[name] = true
appliedEnvVars.Unlock()
count++
}
}
return count, env, nil
}
// WasApplied reports whether ApplyProfile wrote name into the process
// environment. Callers use this to distinguish a profile-supplied default from
// a true launcher/shell env var, because DB-backed runtime overrides should
// beat the former but never the latter.
func WasApplied(name string) bool {
appliedEnvVars.RLock()
defer appliedEnvVars.RUnlock()
return appliedEnvVars.names[name]
}
// MarkApplied records a process env var as runtime/profile-applied rather than
// launcher-explicit. This is for startup code that derives secondary env vars
// from a profile-backed setting after ApplyProfile has already run.
func MarkApplied(name string) {
if !derivedAppliedEnvVars[name] {
return
}
appliedEnvVars.Lock()
defer appliedEnvVars.Unlock()
appliedEnvVars.names[name] = true
}
// parse decodes profiles.yaml into the typed shape. A parse error
// here means someone committed a malformed profiles.yaml; fail loud
// so CI catches it before a release ships.
@@ -132,6 +132,24 @@ func TestFeatureFlagDefaults_LowercasesShortName(t *testing.T) {
}
}
func TestMarkApplied_OnlyAllowsKnownDerivedEnvVars(t *testing.T) {
clearAppliedEnvVars(t)
MarkApplied("KANDEV_DEBUG_AGENT_MESSAGES")
if !WasApplied("KANDEV_DEBUG_AGENT_MESSAGES") {
t.Fatal("KANDEV_DEBUG_AGENT_MESSAGES was not marked applied")
}
MarkApplied("KANDEV_DEBUG_PPROF_ENABLED")
if !WasApplied("KANDEV_DEBUG_PPROF_ENABLED") {
t.Fatal("KANDEV_DEBUG_PPROF_ENABLED was not marked applied")
}
MarkApplied("KANDEV_UNRELATED")
if WasApplied("KANDEV_UNRELATED") {
t.Fatal("KANDEV_UNRELATED was marked applied")
}
}
// TestProfilesYAML_ContainsRequiredSections is a smoke test that
// catches a zero-byte or truncated embed — an embed that silently
// picks up an empty file would make every var default to empty,
@@ -172,3 +190,16 @@ func clearProfilesYAMLVars(t *testing.T) {
t.Cleanup(func() { _ = os.Unsetenv(name) })
}
}
func clearAppliedEnvVars(t *testing.T) {
t.Helper()
appliedEnvVars.Lock()
previous := appliedEnvVars.names
appliedEnvVars.names = map[string]bool{}
appliedEnvVars.Unlock()
t.Cleanup(func() {
appliedEnvVars.Lock()
appliedEnvVars.names = previous
appliedEnvVars.Unlock()
})
}
@@ -0,0 +1,84 @@
package runtimeflags
import (
"os"
"strings"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/profiles"
)
func OptionsFromConfig(cfg *config.Config) Options {
return Options{
DefaultValues: ValuesFromConfig(cfg),
RuntimeValues: ValuesFromConfig(cfg),
EnvValues: map[string]bool{
"KANDEV_FEATURES_OFFICE": isTruthy(os.Getenv("KANDEV_FEATURES_OFFICE")),
"KANDEV_DEBUG_DEV_MODE": isTruthy(os.Getenv("KANDEV_DEBUG_DEV_MODE")),
"KANDEV_DEBUG_PPROF_ENABLED": isTruthy(os.Getenv("KANDEV_DEBUG_PPROF_ENABLED")),
"KANDEV_DEBUG_AGENT_MESSAGES": isTruthy(os.Getenv("KANDEV_DEBUG_AGENT_MESSAGES")),
},
IsExplicitEnv: func(name string) bool {
_, ok := os.LookupEnv(name)
return ok && !profiles.WasApplied(name)
},
}
}
func ValuesFromConfig(cfg *config.Config) map[string]bool {
debugEnabled := cfg.Debug.DevMode || cfg.Debug.PprofEnabled
return map[string]bool{
"features.office": cfg.Features.Office,
"debug.devMode": debugEnabled,
}
}
func ApplyStatesToConfig(cfg *config.Config, states []RuntimeFlagState) {
for _, state := range states {
switch state.Key {
case "features.office":
cfg.Features.Office = state.EffectiveValue
case "debug.devMode":
cfg.Debug.DevMode = state.EffectiveValue
cfg.Debug.PprofEnabled = state.EffectiveValue
if state.EffectiveValue {
setIfNotExplicit("KANDEV_DEBUG_AGENT_MESSAGES", "true")
setIfNotExplicit("KANDEV_DEBUG_PPROF_ENABLED", "true")
} else {
unsetIfNotExplicit("KANDEV_DEBUG_AGENT_MESSAGES")
unsetIfNotExplicit("KANDEV_DEBUG_PPROF_ENABLED")
}
}
}
}
func RuntimeOptionsFromAppliedConfig(defaults map[string]bool, cfg *config.Config) Options {
opts := OptionsFromConfig(cfg)
opts.DefaultValues = defaults
opts.RuntimeValues = ValuesFromConfig(cfg)
return opts
}
func setIfNotExplicit(name, value string) {
if _, ok := os.LookupEnv(name); ok && !profiles.WasApplied(name) {
return
}
_ = os.Setenv(name, value)
profiles.MarkApplied(name)
}
func unsetIfNotExplicit(name string) {
if _, ok := os.LookupEnv(name); ok && !profiles.WasApplied(name) {
return
}
_ = os.Unsetenv(name)
}
func isTruthy(s string) bool {
switch strings.ToLower(s) {
case "true", "1", "yes", "on":
return true
default:
return false
}
}
@@ -0,0 +1,100 @@
package runtimeflags
import (
"os"
"testing"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/profiles"
)
func TestApplyStatesToConfigClearsProfileDebugEnvWhenDisabled(t *testing.T) {
for _, name := range []string{
"KANDEV_DEBUG_DEV_MODE",
"KANDEV_DEBUG_PPROF_ENABLED",
"KANDEV_DEBUG_AGENT_MESSAGES",
} {
preserveEnv(t, name)
}
_ = os.Unsetenv("KANDEV_DEBUG_PPROF_ENABLED")
_ = os.Unsetenv("KANDEV_DEBUG_AGENT_MESSAGES")
t.Setenv("KANDEV_DEBUG_DEV_MODE", "true")
if _, _, err := profiles.ApplyProfile(); err != nil {
t.Fatalf("ApplyProfile: %v", err)
}
if os.Getenv("KANDEV_DEBUG_AGENT_MESSAGES") != "true" {
t.Fatal("profile did not enable agent message debug logs")
}
cfg := &config.Config{}
ApplyStatesToConfig(cfg, []RuntimeFlagState{{
Key: "debug.devMode",
EffectiveValue: false,
}})
if cfg.Debug.DevMode {
t.Fatal("Debug.DevMode = true, want false")
}
if cfg.Debug.PprofEnabled {
t.Fatal("Debug.PprofEnabled = true, want false")
}
if _, ok := os.LookupEnv("KANDEV_DEBUG_AGENT_MESSAGES"); ok {
t.Fatal("KANDEV_DEBUG_AGENT_MESSAGES remained set after disabled override")
}
if _, ok := os.LookupEnv("KANDEV_DEBUG_PPROF_ENABLED"); ok {
t.Fatal("KANDEV_DEBUG_PPROF_ENABLED remained set after disabled override")
}
}
func TestOptionsFromConfigParsesUppercaseTruthyEnv(t *testing.T) {
preserveEnv(t, "KANDEV_FEATURES_OFFICE")
t.Setenv("KANDEV_FEATURES_OFFICE", "TRUE")
opts := OptionsFromConfig(&config.Config{})
if !opts.EnvValues["KANDEV_FEATURES_OFFICE"] {
t.Fatal("KANDEV_FEATURES_OFFICE TRUE parsed false, want true")
}
}
func TestApplyStatesToConfigMarksImpliedDebugEnvAsApplied(t *testing.T) {
for _, name := range []string{
"KANDEV_DEBUG_PPROF_ENABLED",
"KANDEV_DEBUG_AGENT_MESSAGES",
} {
preserveEnv(t, name)
}
cfg := &config.Config{}
ApplyStatesToConfig(cfg, []RuntimeFlagState{{
Key: "debug.devMode",
EffectiveValue: true,
}})
opts := OptionsFromConfig(cfg)
for _, name := range []string{
"KANDEV_DEBUG_PPROF_ENABLED",
"KANDEV_DEBUG_AGENT_MESSAGES",
} {
if !opts.EnvValues[name] {
t.Fatalf("%s was not enabled", name)
}
if opts.IsExplicitEnv(name) {
t.Fatalf("%s reported explicit, want profile-applied", name)
}
}
}
func preserveEnv(t *testing.T, name string) {
t.Helper()
value, ok := os.LookupEnv(name)
_ = os.Unsetenv(name)
t.Cleanup(func() {
if ok {
_ = os.Setenv(name, value)
return
}
_ = os.Unsetenv(name)
})
}
@@ -0,0 +1,58 @@
package runtimeflags
import (
"errors"
"io"
"net/http"
"github.com/gin-gonic/gin"
)
type Handler struct {
svc *Service
}
func RegisterRoutes(router gin.IRouter, svc *Service) {
h := &Handler{svc: svc}
api := router.Group("/api/v1/runtime-flags")
api.GET("", h.list)
api.PATCH("/:key", h.patch)
}
func (h *Handler) list(c *gin.Context) {
states, err := h.svc.ListStates(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read runtime flags"})
return
}
c.JSON(http.StatusOK, gin.H{"flags": states})
}
type patchRequest struct {
Override *bool `json:"override"`
}
func (h *Handler) patch(c *gin.Context) {
var req patchRequest
if err := c.ShouldBindJSON(&req); err != nil {
if !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid runtime flag payload"})
return
}
}
states, err := h.svc.SetOverride(c.Request.Context(), c.Param("key"), req.Override)
if err != nil {
status := http.StatusInternalServerError
msg := "failed to update runtime flag"
if errors.Is(err, ErrEnvLocked) {
status = http.StatusConflict
msg = "runtime flag is controlled by environment"
} else if errors.Is(err, ErrUnknownFlag) {
status = http.StatusNotFound
msg = "runtime flag not found"
}
c.JSON(status, gin.H{"error": msg})
return
}
c.JSON(http.StatusOK, gin.H{"flags": states})
}
@@ -0,0 +1,100 @@
package runtimeflags
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestHandlersPatchRejectsEnvLockedFlag(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := NewService(&memoryStore{}, Options{
DefaultValues: map[string]bool{"features.office": false},
RuntimeValues: map[string]bool{"features.office": true},
EnvValues: map[string]bool{"KANDEV_FEATURES_OFFICE": true},
IsExplicitEnv: func(name string) bool { return name == "KANDEV_FEATURES_OFFICE" },
})
router := gin.New()
RegisterRoutes(router, svc)
req := httptest.NewRequest(http.MethodPatch, "/api/v1/runtime-flags/features.office", strings.NewReader(`{"override":false}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusConflict, rec.Body.String())
}
assertJSONError(t, rec.Body.String(), "runtime flag is controlled by environment")
}
func TestHandlersPatchUnknownFlag(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := NewService(&memoryStore{}, Options{})
router := gin.New()
RegisterRoutes(router, svc)
req := httptest.NewRequest(http.MethodPatch, "/api/v1/runtime-flags/missing.flag", strings.NewReader(`{"override":true}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
}
assertJSONError(t, rec.Body.String(), "runtime flag not found")
}
func TestHandlersPatchMalformedJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := NewService(&memoryStore{}, Options{})
router := gin.New()
RegisterRoutes(router, svc)
req := httptest.NewRequest(http.MethodPatch, "/api/v1/runtime-flags/features.office", strings.NewReader(`{`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
assertJSONError(t, rec.Body.String(), "invalid runtime flag payload")
}
func TestHandlersPatchEmptyBodyClearsOverride(t *testing.T) {
gin.SetMode(gin.TestMode)
store := &memoryStore{values: map[string]bool{"features.office": true}}
svc := NewService(store, Options{})
router := gin.New()
RegisterRoutes(router, svc)
req := httptest.NewRequest(http.MethodPatch, "/api/v1/runtime-flags/features.office", nil)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if _, ok := store.values["features.office"]; ok {
t.Fatal("features.office override still present after empty-body clear")
}
}
func assertJSONError(t *testing.T, body, want string) {
t.Helper()
var payload struct {
Error string `json:"error"`
}
if err := json.Unmarshal([]byte(body), &payload); err != nil {
t.Fatalf("unmarshal error body: %v; body=%s", err, body)
}
if payload.Error != want {
t.Fatalf("error = %q, want %q", payload.Error, want)
}
}
@@ -0,0 +1,49 @@
package runtimeflags
var definitions = []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,
},
{
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{
"KANDEV_DEBUG_PPROF_ENABLED",
"KANDEV_DEBUG_AGENT_MESSAGES",
},
},
}
func Definitions() []RuntimeFlagDefinition {
out := make([]RuntimeFlagDefinition, len(definitions))
copy(out, definitions)
return out
}
func DefinitionByKey(key string) (RuntimeFlagDefinition, bool) {
for _, def := range definitions {
if def.Key == key {
return def, true
}
}
return RuntimeFlagDefinition{}, false
}
@@ -0,0 +1,38 @@
package runtimeflags
import "testing"
func TestDefinitionsIncludeOfficeExperimentalMetadata(t *testing.T) {
def, ok := DefinitionByKey("features.office")
if !ok {
t.Fatal("features.office definition missing")
}
if def.EnvVar != "KANDEV_FEATURES_OFFICE" {
t.Fatalf("EnvVar = %q, want KANDEV_FEATURES_OFFICE", def.EnvVar)
}
if def.Stability != StabilityExperimental {
t.Fatalf("Stability = %q, want %q", def.Stability, StabilityExperimental)
}
if def.RiskDescription == "" {
t.Fatal("RiskDescription empty")
}
if !def.RestartRequired {
t.Fatal("RestartRequired = false, want true")
}
}
func TestDefinitionsExposeSingleUserFacingDebugToggle(t *testing.T) {
def, ok := DefinitionByKey("debug.devMode")
if !ok {
t.Fatal("debug.devMode definition missing")
}
if def.EnvVar != "KANDEV_DEBUG_DEV_MODE" {
t.Fatalf("EnvVar = %q, want KANDEV_DEBUG_DEV_MODE", def.EnvVar)
}
if len(def.ImpliedEnvVars) == 0 {
t.Fatal("Debug mode should imply subordinate debug env vars")
}
if _, ok := DefinitionByKey("debug.agentMessages"); ok {
t.Fatal("debug.agentMessages must not be a top-level user-facing toggle")
}
}
@@ -0,0 +1,136 @@
package runtimeflags
import (
"context"
"errors"
"fmt"
"sort"
)
var (
ErrEnvLocked = errors.New("runtime flag is controlled by environment")
ErrStoreUnset = errors.New("runtime flag store is not configured")
ErrUnknownFlag = errors.New("unknown runtime flag")
)
type Options struct {
DefaultValues map[string]bool
RuntimeValues map[string]bool
EnvValues map[string]bool
IsExplicitEnv func(name string) bool
}
type Service struct {
store Store
opts Options
}
func NewService(store Store, opts Options) *Service {
if opts.IsExplicitEnv == nil {
opts.IsExplicitEnv = func(string) bool { return false }
}
return &Service{store: store, opts: opts}
}
func (s *Service) ListStates(ctx context.Context) ([]RuntimeFlagState, error) {
if s.store == nil {
return nil, ErrStoreUnset
}
overrides, err := s.store.ListOverrides(ctx)
if err != nil {
return nil, err
}
defs := Definitions()
sort.Slice(defs, func(i, j int) bool { return defs[i].Key < defs[j].Key })
states := make([]RuntimeFlagState, 0, len(defs))
for _, def := range defs {
states = append(states, s.stateFor(def, overrides))
}
return states, nil
}
func (s *Service) SetOverride(ctx context.Context, key string, value *bool) ([]RuntimeFlagState, error) {
if s.store == nil {
return nil, ErrStoreUnset
}
def, ok := DefinitionByKey(key)
if !ok {
return nil, fmt.Errorf("%w: %s", ErrUnknownFlag, key)
}
if s.isEnvLocked(def) {
return nil, ErrEnvLocked
}
if value == nil {
if err := s.store.DeleteOverride(ctx, key); err != nil {
return nil, err
}
} else if err := s.store.SetOverride(ctx, key, *value); err != nil {
return nil, err
}
return s.ListStates(ctx)
}
func (s *Service) stateFor(def RuntimeFlagDefinition, overrides map[string]bool) RuntimeFlagState {
defaultValue := s.opts.DefaultValues[def.Key]
runtimeValue := s.opts.RuntimeValues[def.Key]
effectiveValue := defaultValue
source := SourceProfile
var overrideValue *bool
if value, ok := overrides[def.Key]; ok {
v := value
overrideValue = &v
effectiveValue = value
source = SourceOverride
}
if value, locked := s.envLockedValue(def, runtimeValue); locked {
effectiveValue = value
source = SourceEnv
}
if !defaultValue && source == SourceProfile {
source = SourceDefault
}
return RuntimeFlagState{
Key: def.Key,
Kind: def.Kind,
Label: def.Label,
Description: def.Description,
Stability: def.Stability,
RiskLevel: def.RiskLevel,
RiskDescription: def.RiskDescription,
EffectiveValue: effectiveValue,
DefaultValue: defaultValue,
OverrideValue: overrideValue,
Source: source,
EnvVar: def.EnvVar,
EnvLocked: source == SourceEnv,
RestartRequired: def.RestartRequired,
RequiresRestartToApply: def.RestartRequired && effectiveValue != runtimeValue,
Mutable: def.Mutable,
}
}
func (s *Service) isEnvLocked(def RuntimeFlagDefinition) bool {
_, locked := s.envLockedValue(def, false)
return locked
}
func (s *Service) envLockedValue(def RuntimeFlagDefinition, fallback bool) (bool, bool) {
if s.opts.IsExplicitEnv(def.EnvVar) {
return s.envValue(def.EnvVar, fallback), true
}
for _, name := range def.ImpliedEnvVars {
if s.opts.IsExplicitEnv(name) {
return s.envValue(name, fallback), true
}
}
return fallback, false
}
func (s *Service) envValue(name string, fallback bool) bool {
if value, ok := s.opts.EnvValues[name]; ok {
return value
}
return fallback
}
@@ -0,0 +1,124 @@
package runtimeflags
import (
"context"
"errors"
"testing"
)
type memoryStore struct {
values map[string]bool
}
func (m *memoryStore) ListOverrides(context.Context) (map[string]bool, error) {
out := make(map[string]bool, len(m.values))
for k, v := range m.values {
out[k] = v
}
return out, nil
}
func (m *memoryStore) SetOverride(_ context.Context, key string, value bool) error {
if m.values == nil {
m.values = map[string]bool{}
}
m.values[key] = value
return nil
}
func (m *memoryStore) DeleteOverride(_ context.Context, key string) error {
delete(m.values, key)
return nil
}
func TestServiceOverrideWinsOverProfileDefault(t *testing.T) {
svc := NewService(&memoryStore{values: map[string]bool{"features.office": true}}, Options{
DefaultValues: map[string]bool{"features.office": false},
RuntimeValues: map[string]bool{"features.office": false},
IsExplicitEnv: func(string) bool { return false },
})
states, err := svc.ListStates(context.Background())
if err != nil {
t.Fatalf("ListStates: %v", err)
}
office := stateByKey(t, states, "features.office")
if !office.EffectiveValue {
t.Fatal("EffectiveValue = false, want true")
}
if office.Source != SourceOverride {
t.Fatalf("Source = %q, want %q", office.Source, SourceOverride)
}
if !office.RequiresRestartToApply {
t.Fatal("RequiresRestartToApply = false, want true")
}
}
func TestServiceExplicitEnvLocksOverride(t *testing.T) {
svc := NewService(&memoryStore{values: map[string]bool{"features.office": false}}, Options{
DefaultValues: map[string]bool{"features.office": false},
RuntimeValues: map[string]bool{"features.office": true},
EnvValues: map[string]bool{"KANDEV_FEATURES_OFFICE": true},
IsExplicitEnv: func(name string) bool { return name == "KANDEV_FEATURES_OFFICE" },
})
states, err := svc.ListStates(context.Background())
if err != nil {
t.Fatalf("ListStates: %v", err)
}
office := stateByKey(t, states, "features.office")
if !office.EffectiveValue {
t.Fatal("EffectiveValue = false, want true")
}
if office.Source != SourceEnv {
t.Fatalf("Source = %q, want %q", office.Source, SourceEnv)
}
if !office.EnvLocked {
t.Fatal("EnvLocked = false, want true")
}
}
func TestServiceExplicitImpliedEnvLocksDebugMode(t *testing.T) {
svc := NewService(&memoryStore{values: map[string]bool{"debug.devMode": false}}, Options{
DefaultValues: map[string]bool{"debug.devMode": false},
RuntimeValues: map[string]bool{"debug.devMode": false},
EnvValues: map[string]bool{
"KANDEV_DEBUG_DEV_MODE": false,
"KANDEV_DEBUG_PPROF_ENABLED": true,
},
IsExplicitEnv: func(name string) bool { return name == "KANDEV_DEBUG_PPROF_ENABLED" },
})
states, err := svc.ListStates(context.Background())
if err != nil {
t.Fatalf("ListStates: %v", err)
}
debug := stateByKey(t, states, "debug.devMode")
if !debug.EffectiveValue {
t.Fatal("EffectiveValue = false, want true")
}
if debug.Source != SourceEnv {
t.Fatalf("Source = %q, want %q", debug.Source, SourceEnv)
}
if !debug.EnvLocked {
t.Fatal("EnvLocked = false, want true")
}
}
func TestServiceNilStoreFailsFast(t *testing.T) {
svc := NewService(nil, Options{})
if _, err := svc.ListStates(context.Background()); !errors.Is(err, ErrStoreUnset) {
t.Fatalf("ListStates error = %v, want %v", err, ErrStoreUnset)
}
if _, err := svc.SetOverride(context.Background(), "features.office", nil); !errors.Is(err, ErrStoreUnset) {
t.Fatalf("SetOverride error = %v, want %v", err, ErrStoreUnset)
}
}
func stateByKey(t *testing.T, states []RuntimeFlagState, key string) RuntimeFlagState {
t.Helper()
for _, state := range states {
if state.Key == key {
return state
}
}
t.Fatalf("state %q missing", key)
return RuntimeFlagState{}
}
@@ -0,0 +1,80 @@
package runtimeflags
import (
"context"
"fmt"
"time"
"github.com/jmoiron/sqlx"
)
type SQLiteStore struct {
writer *sqlx.DB
reader *sqlx.DB
}
func NewSQLiteStore(writer, reader *sqlx.DB) (*SQLiteStore, error) {
store := &SQLiteStore{writer: writer, reader: reader}
if err := store.initSchema(); err != nil {
return nil, err
}
return store, nil
}
func (s *SQLiteStore) initSchema() error {
_, err := s.writer.Exec(`CREATE TABLE IF NOT EXISTS runtime_flag_overrides (
key TEXT PRIMARY KEY,
value INTEGER NOT NULL CHECK (value IN (0, 1)),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return fmt.Errorf("create runtime_flag_overrides: %w", err)
}
return nil
}
func (s *SQLiteStore) ListOverrides(ctx context.Context) (map[string]bool, error) {
rows, err := s.reader.QueryxContext(ctx, `SELECT key, value FROM runtime_flag_overrides`)
if err != nil {
return nil, fmt.Errorf("list runtime flag overrides: %w", err)
}
defer func() { _ = rows.Close() }()
out := map[string]bool{}
for rows.Next() {
var key string
var value int
if err := rows.Scan(&key, &value); err != nil {
return nil, fmt.Errorf("scan runtime flag override: %w", err)
}
out[key] = value != 0
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate runtime flag overrides: %w", err)
}
return out, nil
}
func (s *SQLiteStore) SetOverride(ctx context.Context, key string, value bool) error {
now := time.Now().UTC()
intValue := 0
if value {
intValue = 1
}
_, err := s.writer.ExecContext(ctx, `INSERT INTO runtime_flag_overrides (key, value, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
key, intValue, now, now)
if err != nil {
return fmt.Errorf("set runtime flag override %q: %w", key, err)
}
return nil
}
func (s *SQLiteStore) DeleteOverride(ctx context.Context, key string) error {
if _, err := s.writer.ExecContext(ctx, `DELETE FROM runtime_flag_overrides WHERE key = ?`, key); err != nil {
return fmt.Errorf("delete runtime flag override %q: %w", key, err)
}
return nil
}
@@ -0,0 +1,63 @@
package runtimeflags
import (
"context"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
)
func TestSQLiteStoreRoundTripsFalseOverride(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
if err := store.SetOverride(ctx, "features.office", false); err != nil {
t.Fatalf("SetOverride false: %v", err)
}
overrides, err := store.ListOverrides(ctx)
if err != nil {
t.Fatalf("ListOverrides: %v", err)
}
value, ok := overrides["features.office"]
if !ok {
t.Fatal("features.office override missing")
}
if value {
t.Fatal("features.office override = true, want false")
}
}
func TestSQLiteStoreDeleteOverrideRestoresMissing(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
if err := store.SetOverride(ctx, "features.office", true); err != nil {
t.Fatalf("SetOverride true: %v", err)
}
if err := store.DeleteOverride(ctx, "features.office"); err != nil {
t.Fatalf("DeleteOverride: %v", err)
}
overrides, err := store.ListOverrides(ctx)
if err != nil {
t.Fatalf("ListOverrides: %v", err)
}
if _, ok := overrides["features.office"]; ok {
t.Fatal("features.office override still present after delete")
}
}
func newTestStore(t *testing.T) *SQLiteStore {
t.Helper()
db, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
store, err := NewSQLiteStore(db, db)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
return store
}
@@ -0,0 +1,74 @@
package runtimeflags
import "context"
type RuntimeFlagKind string
const (
KindFeature RuntimeFlagKind = "feature"
KindDebug RuntimeFlagKind = "debug"
)
type RuntimeFlagSource string
const (
SourceEnv RuntimeFlagSource = "env"
SourceOverride RuntimeFlagSource = "override"
SourceProfile RuntimeFlagSource = "profile"
SourceDefault RuntimeFlagSource = "default"
)
type RuntimeFlagStability string
const (
StabilityStable RuntimeFlagStability = "stable"
StabilityBeta RuntimeFlagStability = "beta"
StabilityExperimental RuntimeFlagStability = "experimental"
)
type RuntimeFlagRiskLevel string
const (
RiskLow RuntimeFlagRiskLevel = "low"
RiskMedium RuntimeFlagRiskLevel = "medium"
RiskHigh RuntimeFlagRiskLevel = "high"
)
type RuntimeFlagDefinition struct {
Key string `json:"key"`
EnvVar string `json:"env_var"`
Kind RuntimeFlagKind `json:"kind"`
Label string `json:"label"`
Description string `json:"description"`
Stability RuntimeFlagStability `json:"stability"`
RiskLevel RuntimeFlagRiskLevel `json:"risk_level"`
RiskDescription string `json:"risk_description"`
RestartRequired bool `json:"restart_required"`
Mutable bool `json:"mutable"`
ImpliedEnvVars []string `json:"-"`
}
type RuntimeFlagState struct {
Key string `json:"key"`
Kind RuntimeFlagKind `json:"kind"`
Label string `json:"label"`
Description string `json:"description"`
Stability RuntimeFlagStability `json:"stability"`
RiskLevel RuntimeFlagRiskLevel `json:"risk_level"`
RiskDescription string `json:"risk_description"`
EffectiveValue bool `json:"effective_value"`
DefaultValue bool `json:"default_value"`
OverrideValue *bool `json:"override_value"`
Source RuntimeFlagSource `json:"source"`
EnvVar string `json:"env_var"`
EnvLocked bool `json:"env_locked"`
RestartRequired bool `json:"restart_required"`
RequiresRestartToApply bool `json:"requires_restart_to_apply"`
Mutable bool `json:"mutable"`
}
type Store interface {
ListOverrides(ctx context.Context) (map[string]bool, error)
SetOverride(ctx context.Context, key string, value bool) error
DeleteOverride(ctx context.Context, key string) error
}
+25 -1
View File
@@ -4,8 +4,12 @@
package info
import (
"crypto/rand"
"encoding/binary"
"encoding/hex"
"net/http"
"runtime"
"time"
"github.com/gin-gonic/gin"
)
@@ -18,6 +22,8 @@ type Response struct {
GoVersion string `json:"go_version"`
OS string `json:"os"`
Arch string `json:"arch"`
BootID string `json:"boot_id"`
StartedAt string `json:"started_at"`
}
// Service composes the static build info; instantiated once at boot from
@@ -27,11 +33,19 @@ type Service struct {
Version string
Commit string
BuildTime string
BootID string
StartedAt time.Time
}
// NewService constructs a Service from the ldflag-injected build values.
func NewService(version, commit, buildTime string) *Service {
return &Service{Version: version, Commit: commit, BuildTime: buildTime}
return &Service{
Version: version,
Commit: commit,
BuildTime: buildTime,
BootID: newBootID(),
StartedAt: time.Now().UTC(),
}
}
// Info renders the response payload.
@@ -43,6 +57,8 @@ func (s *Service) Info() Response {
GoVersion: runtime.Version(),
OS: runtime.GOOS,
Arch: runtime.GOARCH,
BootID: s.BootID,
StartedAt: s.StartedAt.Format(time.RFC3339Nano),
}
}
@@ -52,3 +68,11 @@ func Handler(s *Service) gin.HandlerFunc {
c.JSON(http.StatusOK, s.Info())
}
}
func newBootID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
binary.BigEndian.PutUint64(b[8:], uint64(time.Now().UTC().UnixNano()))
}
return hex.EncodeToString(b[:])
}
@@ -3,6 +3,7 @@ package info
import (
"encoding/json"
"net/http/httptest"
"regexp"
"runtime"
"testing"
@@ -30,6 +31,15 @@ func TestInfo_ReturnsConfiguredAndRuntimeFields(t *testing.T) {
if got.Arch != runtime.GOARCH {
t.Errorf("Arch = %q", got.Arch)
}
if got.BootID == "" {
t.Fatal("BootID empty")
}
if got.StartedAt == "" {
t.Fatal("StartedAt empty")
}
if again := s.Info(); again.BootID != got.BootID {
t.Fatalf("BootID changed within process: %q != %q", again.BootID, got.BootID)
}
}
func TestHandler_RendersJSON(t *testing.T) {
@@ -52,4 +62,17 @@ func TestHandler_RendersJSON(t *testing.T) {
if resp.Version != "v1.2.3" {
t.Errorf("Version = %q", resp.Version)
}
if resp.BootID == "" {
t.Fatal("BootID empty")
}
if resp.StartedAt == "" {
t.Fatal("StartedAt empty")
}
}
func TestNewBootID_ReturnsHexEncodedBytes(t *testing.T) {
got := newBootID()
if matched := regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(got); !matched {
t.Fatalf("BootID = %q, want 32 lowercase hex chars", got)
}
}
@@ -0,0 +1,38 @@
package restart
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
)
func HandleCapability(manager Manager) gin.HandlerFunc {
if manager == nil {
manager = NewUnsupportedManager("")
}
return func(c *gin.Context) {
c.JSON(http.StatusOK, manager.Capability(c.Request.Context()))
}
}
func HandleRequest(manager Manager) gin.HandlerFunc {
if manager == nil {
manager = NewUnsupportedManager("")
}
return func(c *gin.Context) {
resp, err := manager.RequestRestart(c.Request.Context())
if errors.Is(err, ErrUnsupported) {
c.JSON(http.StatusNotImplemented, resp)
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, RestartResponse{
Accepted: false,
Message: err.Error(),
})
return
}
c.JSON(http.StatusAccepted, resp)
}
}
@@ -0,0 +1,116 @@
package restart
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestHandleCapabilityReportsUnsupportedManager(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/api/v1/system/restart-capability", HandleCapability(NewUnsupportedManager("")))
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/restart-capability", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var body Capability
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body.Supported {
t.Fatal("Supported = true, want false")
}
if body.Mode != ModeManual {
t.Fatalf("Mode = %q, want %q", body.Mode, ModeManual)
}
if body.Adapter != AdapterUnsupported {
t.Fatalf("Adapter = %q, want %q", body.Adapter, AdapterUnsupported)
}
if body.Reason == "" {
t.Fatal("Reason empty, want manual restart guidance")
}
}
func TestHandleRequestRejectsUnsupportedRestart(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/api/v1/system/restart", HandleRequest(NewUnsupportedManager("")))
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/restart", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotImplemented {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNotImplemented, rec.Body.String())
}
var body RestartResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body.Accepted {
t.Fatal("Accepted = true, want false")
}
}
func TestHandleRequestDelegatesToSupportedManager(t *testing.T) {
gin.SetMode(gin.TestMode)
mgr := &fakeManager{
resp: RestartResponse{Accepted: true, Message: "Restart requested."},
}
r := gin.New()
r.POST("/api/v1/system/restart", HandleRequest(mgr))
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/restart", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
if mgr.requestCalls != 1 {
t.Fatalf("requestCalls = %d, want 1", mgr.requestCalls)
}
}
func TestHandleRequestReportsManagerError(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/api/v1/system/restart", HandleRequest(&fakeManager{err: errors.New("boom")}))
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/restart", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusInternalServerError, rec.Body.String())
}
}
type fakeManager struct {
resp RestartResponse
err error
requestCalls int
}
func (m *fakeManager) Capability(context.Context) Capability {
return Capability{
Supported: true,
Mode: ModeSupervisor,
Adapter: AdapterSupervisor,
}
}
func (m *fakeManager) RequestRestart(context.Context) (RestartResponse, error) {
m.requestCalls++
return m.resp, m.err
}
@@ -0,0 +1,36 @@
package restart
import (
"context"
"errors"
)
type Capability struct {
Supported bool `json:"supported"`
Mode string `json:"mode"`
Adapter string `json:"adapter"`
Reason string `json:"reason,omitempty"`
Details map[string]interface{} `json:"details,omitempty"`
}
type RestartResponse struct {
Accepted bool `json:"accepted"`
Message string `json:"message"`
}
type Manager interface {
Capability(ctx context.Context) Capability
RequestRestart(ctx context.Context) (RestartResponse, error)
}
const (
AdapterUnsupported = "unsupported"
AdapterSupervisor = "supervisor"
ModeManual = "manual"
ModeSupervisor = "supervisor"
unsupportedReason = "Automatic restart is not available for this launch mode. Restart Kandev from the terminal or service manager."
)
var ErrUnsupported = errors.New("restart unsupported")
@@ -0,0 +1,132 @@
package restart
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net"
"os"
"runtime"
"time"
)
const (
EnvSupervisorSocket = "KANDEV_SUPERVISOR_SOCKET"
EnvSupervisorManifest = "KANDEV_SUPERVISOR_MANIFEST"
EnvRestartAdapter = "KANDEV_RESTART_ADAPTER"
controlTimeout = 2 * time.Second
)
type SupervisorManager struct {
SocketPath string
Manifest string
Dial func(ctx context.Context, network, address string) (net.Conn, error)
}
func NewManagerFromEnv() Manager {
adapter := os.Getenv(EnvRestartAdapter)
socket := os.Getenv(EnvSupervisorSocket)
if adapter == AdapterSupervisor && socket == "" {
return NewUnsupportedManager(fmt.Sprintf("%s is required when %s=%s.", EnvSupervisorSocket, EnvRestartAdapter, AdapterSupervisor))
}
if adapter == AdapterSupervisor || socket != "" {
return NewSupervisorManager(socket, os.Getenv(EnvSupervisorManifest))
}
return NewUnsupportedManager("")
}
func NewSupervisorManager(socketPath, manifest string) *SupervisorManager {
return &SupervisorManager{
SocketPath: socketPath,
Manifest: manifest,
Dial: (&net.Dialer{
Timeout: controlTimeout,
}).DialContext,
}
}
func (m *SupervisorManager) Capability(ctx context.Context) Capability {
if runtime.GOOS == "windows" {
return m.unsupported("Supervisor restart is not available on Windows yet.")
}
if m.SocketPath == "" {
return m.unsupported("Supervisor restart is not configured for this launch mode.")
}
if err := m.probe(ctx); err != nil {
return m.unsupported("Supervisor restart is configured but unavailable: " + err.Error())
}
return Capability{
Supported: true,
Mode: ModeSupervisor,
Adapter: AdapterSupervisor,
Details: map[string]interface{}{
"restart_requires_ui_poll": true,
},
}
}
func (m *SupervisorManager) RequestRestart(ctx context.Context) (RestartResponse, error) {
if cap := m.Capability(ctx); !cap.Supported {
return RestartResponse{Accepted: false, Message: cap.Reason}, ErrUnsupported
}
resp, err := m.send(ctx, map[string]string{"action": "restart"})
if err != nil {
return RestartResponse{}, err
}
if !resp.Accepted {
return RestartResponse{Accepted: false, Message: resp.Message}, fmt.Errorf("supervisor rejected restart: %s", resp.Message)
}
return RestartResponse{Accepted: true, Message: "Restart requested. Kandev will be unavailable briefly."}, nil
}
func (m *SupervisorManager) probe(ctx context.Context) error {
conn, err := m.dial(ctx)
if err != nil {
return err
}
return conn.Close()
}
func (m *SupervisorManager) send(ctx context.Context, req map[string]string) (RestartResponse, error) {
conn, err := m.dial(ctx)
if err != nil {
return RestartResponse{}, err
}
defer func() { _ = conn.Close() }()
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
} else {
_ = conn.SetDeadline(time.Now().Add(controlTimeout))
}
if err := json.NewEncoder(conn).Encode(req); err != nil {
return RestartResponse{}, err
}
line, err := bufio.NewReader(conn).ReadBytes('\n')
if err != nil {
return RestartResponse{}, err
}
var resp RestartResponse
if err := json.Unmarshal(line, &resp); err != nil {
return RestartResponse{}, err
}
return resp, nil
}
func (m *SupervisorManager) dial(ctx context.Context) (net.Conn, error) {
dial := m.Dial
if dial == nil {
dial = (&net.Dialer{Timeout: controlTimeout}).DialContext
}
return dial(ctx, "unix", m.SocketPath)
}
func (m *SupervisorManager) unsupported(reason string) Capability {
return Capability{
Supported: false,
Mode: ModeManual,
Adapter: AdapterUnsupported,
Reason: reason,
}
}
@@ -0,0 +1,113 @@
package restart
import (
"bufio"
"context"
"encoding/json"
"net"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSupervisorManagerReportsUnsupportedWithoutSocket(t *testing.T) {
mgr := NewSupervisorManager("", "")
cap := mgr.Capability(context.Background())
if cap.Supported {
t.Fatal("Supported = true, want false")
}
if cap.Adapter != AdapterUnsupported {
t.Fatalf("Adapter = %q, want %q", cap.Adapter, AdapterUnsupported)
}
if cap.Reason == "" {
t.Fatal("Reason empty")
}
}
func TestNewManagerFromEnvReportsMissingSupervisorSocket(t *testing.T) {
t.Setenv(EnvRestartAdapter, AdapterSupervisor)
t.Setenv(EnvSupervisorSocket, "")
cap := NewManagerFromEnv().Capability(context.Background())
if cap.Supported {
t.Fatal("Supported = true, want false")
}
if !strings.Contains(cap.Reason, EnvSupervisorSocket) {
t.Fatalf("Reason = %q, want mention %s", cap.Reason, EnvSupervisorSocket)
}
}
func TestSupervisorManagerRequestsRestart(t *testing.T) {
socket := filepath.Join(os.TempDir(), "kdv-supervisor-test.sock")
_ = os.Remove(socket)
t.Cleanup(func() { _ = os.Remove(socket) })
requests := make(chan map[string]string, 1)
closeServer := startFakeSupervisor(t, socket, requests)
defer closeServer()
mgr := NewSupervisorManager(socket, "")
cap := mgr.Capability(context.Background())
if !cap.Supported {
t.Fatalf("Supported = false, reason=%q", cap.Reason)
}
if cap.Adapter != AdapterSupervisor {
t.Fatalf("Adapter = %q, want %q", cap.Adapter, AdapterSupervisor)
}
resp, err := mgr.RequestRestart(context.Background())
if err != nil {
t.Fatalf("RequestRestart: %v", err)
}
if !resp.Accepted {
t.Fatalf("Accepted = false, message=%q", resp.Message)
}
req := <-requests
if req["action"] != "restart" {
t.Fatalf("action = %q, want restart", req["action"])
}
}
func startFakeSupervisor(
t *testing.T,
socket string,
requests chan<- map[string]string,
) func() {
t.Helper()
ln, err := net.Listen("unix", socket)
if err != nil {
t.Fatalf("listen: %v", err)
}
done := make(chan struct{})
go func() {
defer close(done)
for {
conn, err := ln.Accept()
if err != nil {
return
}
go handleFakeSupervisorConn(conn, requests)
}
}()
return func() {
_ = ln.Close()
<-done
}
}
func handleFakeSupervisorConn(conn net.Conn, requests chan<- map[string]string) {
defer func() { _ = conn.Close() }()
line, err := bufio.NewReader(conn).ReadBytes('\n')
if err != nil {
return
}
var req map[string]string
if err := json.Unmarshal(line, &req); err == nil {
requests <- req
}
_ = json.NewEncoder(conn).Encode(RestartResponse{
Accepted: true,
Message: "Restart accepted",
})
}
@@ -0,0 +1,34 @@
package restart
import "context"
type UnsupportedManager struct {
Reason string
}
func NewUnsupportedManager(reason string) *UnsupportedManager {
return &UnsupportedManager{Reason: reason}
}
func (m *UnsupportedManager) Capability(context.Context) Capability {
return Capability{
Supported: false,
Mode: ModeManual,
Adapter: AdapterUnsupported,
Reason: m.reason(),
}
}
func (m *UnsupportedManager) RequestRestart(context.Context) (RestartResponse, error) {
return RestartResponse{
Accepted: false,
Message: m.reason(),
}, ErrUnsupported
}
func (m *UnsupportedManager) reason() string {
if m != nil && m.Reason != "" {
return m.Reason
}
return unsupportedReason
}
+5
View File
@@ -26,6 +26,7 @@ import (
"github.com/kandev/kandev/internal/system/jobs"
"github.com/kandev/kandev/internal/system/logs"
"github.com/kandev/kandev/internal/system/metrics"
"github.com/kandev/kandev/internal/system/restart"
systemsettings "github.com/kandev/kandev/internal/system/settings"
"github.com/kandev/kandev/internal/system/updates"
"go.uber.org/zap"
@@ -60,6 +61,7 @@ type Service struct {
Logs *logs.Service
Metrics *metrics.Service
Updates *updates.Service
Restart restart.Manager
}
// Provide constructs the composed Service. The HTTP routes are
@@ -104,6 +106,7 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus
Logs: logs.NewService(logDir, logFile, log),
Metrics: metricsSvc,
Updates: updates.NewService(pool, build.Version, nil, log, updates.WithHomeDir(homeDir), updates.WithJobs(tracker)),
Restart: restart.NewManagerFromEnv(),
}
}
@@ -137,6 +140,8 @@ func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) {
g.GET("/updates", updates.HandleGet(s.Updates))
g.POST("/updates/check", updates.HandleCheck(s.Updates))
g.POST("/updates/apply", updates.HandleApply(s.Updates))
g.GET("/restart-capability", restart.HandleCapability(s.Restart))
g.POST("/restart", restart.HandleRequest(s.Restart))
g.GET("/jobs/:id", jobs.HandleGet(s.Jobs))
+1
View File
@@ -138,6 +138,7 @@ async function main(): Promise<void> {
webPort,
verbose: options.verbose,
debug: options.debug,
headless: options.headless,
});
return;
}
+10 -13
View File
@@ -1,4 +1,3 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
@@ -7,13 +6,8 @@ import { devKandevHome, HEALTH_TIMEOUT_MS_DEV } from "./constants";
import { resolveHealthTimeoutMs, waitForHealth, waitForUrlReady } from "./health";
import { isInsideKandevTask } from "./kandev-env";
import { createProcessSupervisor } from "./process";
import {
attachBackendExitHandler,
buildBackendEnv,
buildWebEnv,
logStartupInfo,
pickPorts,
} from "./shared";
import { buildBackendEnv, buildWebEnv, logStartupInfo, pickPorts } from "./shared";
import { launchRestartableBackend } from "./supervisor/backend";
import { launchWebApp, openBrowser } from "./web";
export type DevOptions = {
@@ -64,18 +58,21 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr
path.join("apps", "backend"),
"dev",
]);
const backendProc = spawn(backendCmd, backendArgs, {
const backend = await launchRestartableBackend({
command: backendCmd,
args: backendArgs,
cwd: repoRoot,
env: backendEnv,
homeDir: backendEnv.KANDEV_HOME_DIR ?? devKandevHome(repoRoot),
ports,
mode: "dev",
stdio: "inherit",
supervisor,
});
supervisor.children.push(backendProc);
attachBackendExitHandler(backendProc, supervisor);
const healthTimeoutMs = resolveHealthTimeoutMs(HEALTH_TIMEOUT_MS_DEV);
console.log("[kandev] starting backend...");
await waitForHealth(ports.backendUrl, backendProc, healthTimeoutMs);
await waitForHealth(ports.backendUrl, backend.proc, healthTimeoutMs);
console.log(`[kandev] backend ready at ${ports.backendUrl}`);
console.log("[kandev] starting web...");
+19 -8
View File
@@ -11,6 +11,7 @@ import {
resolveCacheDir,
resolveDataDir,
resolveDatabasePath,
resolveKandevHomeDir,
} from "./constants";
import { ensureAsset, getRelease } from "./github";
import { resolveHealthTimeoutMs, waitForHealth, waitForUrlReady } from "./health";
@@ -19,7 +20,8 @@ import { sortVersionsDesc } from "./version";
import { pickAvailablePort } from "./ports";
import { createProcessSupervisor } from "./process";
import { resolveRuntime, validateBundle } from "./runtime";
import { attachBackendExitHandler, buildBackendEnv, buildWebEnv, logStartupInfo } from "./shared";
import { buildBackendEnv, buildWebEnv, logStartupInfo } from "./shared";
import { launchRestartableBackend } from "./supervisor/backend";
import { launchWebApp, openBrowser } from "./web";
export type RunOptions = {
@@ -254,12 +256,12 @@ export function attachRingBuffer(
return () => buf;
}
function launchBundle(prepared: PreparedBundle): {
async function launchBundle(prepared: PreparedBundle): Promise<{
supervisor: ReturnType<typeof createProcessSupervisor>;
backendProc: ReturnType<typeof spawn>;
webServerPath: string;
dumpBackendLogs: () => void;
} {
}> {
logStartupInfo({
header: `release: ${prepared.releaseTag}`,
ports: {
@@ -275,12 +277,23 @@ function launchBundle(prepared: PreparedBundle): {
const supervisor = createProcessSupervisor();
supervisor.attachSignalHandlers();
const backendProc = spawn(prepared.backendBin, [], {
const backend = await launchRestartableBackend({
command: prepared.backendBin,
args: [],
cwd: path.dirname(prepared.backendBin),
env: prepared.backendEnv,
homeDir: resolveKandevHomeDir(),
ports: {
backendPort: Number(prepared.backendEnv.KANDEV_SERVER_PORT),
webPort: prepared.webPort,
agentctlPort: prepared.agentctlPort,
backendUrl: prepared.backendUrl,
},
mode: "run",
stdio: prepared.showOutput ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "inherit"],
supervisor,
});
supervisor.children.push(backendProc);
const backendProc = backend.proc;
const readBuffered = prepared.showOutput ? () => "" : attachRingBuffer(backendProc.stdout);
let dumped = false;
@@ -294,8 +307,6 @@ function launchBundle(prepared: PreparedBundle): {
console.error("[kandev] --- end backend stdout ---");
};
attachBackendExitHandler(backendProc, supervisor);
const webServerPath = resolveWebServerPath(prepared.bundleDir);
if (!webServerPath) {
throw new Error("Web server entry (server.js) not found in bundle");
@@ -319,7 +330,7 @@ export async function runRelease({
verbose,
debug,
});
const { supervisor, backendProc, webServerPath, dumpBackendLogs } = launchBundle(prepared);
const { supervisor, backendProc, webServerPath, dumpBackendLogs } = await launchBundle(prepared);
const healthTimeoutMs = resolveHealthTimeoutMs(HEALTH_TIMEOUT_MS_RELEASE);
console.log("[kandev] starting backend...");
await waitForHealth(prepared.backendUrl, backendProc, healthTimeoutMs, dumpBackendLogs);
+4
View File
@@ -244,9 +244,13 @@ export function networkUrlsForPort(port: number, hosts: string[]): string[] {
export function attachBackendExitHandler(
backendProc: ChildProcess,
supervisor: ReturnType<typeof createProcessSupervisor>,
options: { shouldShutdown?: () => boolean } = {},
): void {
backendProc.on("exit", (code, signal) => {
console.error(`[kandev] backend exited (code=${code}, signal=${signal})`);
if (options.shouldShutdown && !options.shouldShutdown()) {
return;
}
const exitCode = signal ? 0 : (code ?? 1);
void supervisor.shutdown("backend exit").then(() => process.exit(exitCode));
});
+23 -14
View File
@@ -11,21 +11,20 @@
* - Or simply: `make build` (builds both)
*/
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { HEALTH_TIMEOUT_MS_RELEASE, resolveDataDir, resolveDatabasePath } from "./constants";
import {
HEALTH_TIMEOUT_MS_RELEASE,
resolveDataDir,
resolveDatabasePath,
resolveKandevHomeDir,
} from "./constants";
import { resolveHealthTimeoutMs, waitForHealth, waitForUrlReady } from "./health";
import { getBinaryName } from "./platform";
import { createProcessSupervisor } from "./process";
import {
attachBackendExitHandler,
buildBackendEnv,
buildWebEnv,
logStartupInfo,
pickPorts,
} from "./shared";
import { buildBackendEnv, buildWebEnv, logStartupInfo, pickPorts } from "./shared";
import { launchRestartableBackend } from "./supervisor/backend";
import { launchWebApp, openBrowser } from "./web";
/**
@@ -82,6 +81,8 @@ export type StartOptions = {
verbose?: boolean;
/** Show debug logs + agent message dumps */
debug?: boolean;
/** Skip browser open. Set by service units and preview environments. */
headless?: boolean;
};
/**
@@ -103,6 +104,7 @@ export async function runStart({
webPort,
verbose = false,
debug = false,
headless = false,
}: StartOptions): Promise<void> {
const ports = await pickPorts(backendPort, webPort);
@@ -183,18 +185,21 @@ export async function runStart({
// Start backend: ignore stdin, show stdout only in verbose/debug mode, always show stderr
// Stderr is always inherited to ensure error messages are visible immediately (no pipe buffering)
const backendProc = spawn(backendBin, [], {
const backend = await launchRestartableBackend({
command: backendBin,
args: [],
cwd: path.dirname(backendBin),
env: backendEnv,
homeDir: resolveKandevHomeDir(),
ports,
mode: "start",
stdio: showOutput ? ["ignore", "inherit", "inherit"] : ["ignore", "ignore", "inherit"],
supervisor,
});
supervisor.children.push(backendProc);
attachBackendExitHandler(backendProc, supervisor);
const healthTimeoutMs = resolveHealthTimeoutMs(HEALTH_TIMEOUT_MS_RELEASE);
console.log("[kandev] starting backend...");
await waitForHealth(ports.backendUrl, backendProc, healthTimeoutMs);
await waitForHealth(ports.backendUrl, backend.proc, healthTimeoutMs);
console.log(`[kandev] backend ready at ${ports.backendUrl}`);
// Use standalone server.js directly (not pnpm start)
@@ -212,5 +217,9 @@ export async function runStart({
await waitForUrlReady(webUrl, webProc, healthTimeoutMs);
console.log("[kandev] open: " + ports.backendUrl);
if (headless) {
console.log(`[kandev] ready (headless) at ${ports.backendUrl}`);
return;
}
openBrowser(ports.backendUrl);
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { shouldUseSupervisor } from "./backend";
describe("backend supervisor launch policy", () => {
it("uses the supervisor by default", () => {
expect(shouldUseSupervisor({})).toBe(true);
});
it("can be disabled with KANDEV_NO_SUPERVISOR", () => {
expect(shouldUseSupervisor({ KANDEV_NO_SUPERVISOR: "true" })).toBe(false);
expect(shouldUseSupervisor({ KANDEV_NO_SUPERVISOR: "1" })).toBe(true);
});
});
+117
View File
@@ -0,0 +1,117 @@
import { spawn, spawnSync, type ChildProcess, type StdioOptions } from "node:child_process";
import path from "node:path";
import type { PortConfig } from "../shared";
import { attachBackendExitHandler } from "../shared";
import type { ChildLike, createProcessSupervisor } from "../process";
import { buildLaunchManifest, writeLaunchManifest } from "./manifest";
import { manifestPath, prepareSupervisorDir, socketPath } from "./paths";
import { createRestartableChild } from "./child";
import { startControlServer, type ControlServer } from "./control";
export type BackendSupervisorHandle = {
proc: ChildProcess;
control: ControlServer | null;
env: NodeJS.ProcessEnv;
};
export async function launchRestartableBackend({
command,
args,
cwd,
env,
homeDir,
ports,
mode,
stdio,
supervisor,
}: {
command: string;
args: string[];
cwd: string;
env: NodeJS.ProcessEnv;
homeDir: string;
ports: PortConfig;
mode: string;
stdio: StdioOptions;
supervisor: ReturnType<typeof createProcessSupervisor>;
}): Promise<BackendSupervisorHandle> {
if (!shouldUseSupervisor(env)) {
const proc = spawn(command, args, { cwd, env, stdio });
supervisor.children.push(proc as ChildLike);
attachBackendExitHandler(proc, supervisor);
return { proc, control: null, env };
}
const supervisorEnv = withSupervisorEnv(env, homeDir);
const manifest = buildLaunchManifest({
backend_executable: resolveExecutable(command),
argv: args,
cwd,
env: supervisorEnv,
home_dir: homeDir,
port: ports.backendPort,
mode,
});
writeLaunchManifest(manifest, supervisorEnv.KANDEV_SUPERVISOR_MANIFEST);
const child = createRestartableChild(manifest, { stdio, extraEnv: supervisorEnv });
let restarting = false;
const attachExit = (proc: ChildProcess) => {
attachBackendExitHandler(proc, supervisor, {
shouldShutdown: () => !restarting,
});
};
const proc = child.start();
supervisor.children.push(proc as ChildLike);
attachExit(proc);
const control = await startControlServer(supervisorEnv.KANDEV_SUPERVISOR_SOCKET, async () => {
restarting = true;
try {
const next = await child.restart();
supervisor.children.push(next as ChildLike);
attachExit(next);
} finally {
restarting = false;
}
});
return { proc, control, env: supervisorEnv };
}
export function shouldUseSupervisor(env: NodeJS.ProcessEnv = process.env): boolean {
return env.KANDEV_NO_SUPERVISOR !== "true";
}
export function withSupervisorEnv(
env: NodeJS.ProcessEnv,
homeDir: string,
): NodeJS.ProcessEnv & {
KANDEV_SUPERVISOR_SOCKET: string;
KANDEV_SUPERVISOR_MANIFEST: string;
KANDEV_RESTART_ADAPTER: string;
} {
prepareSupervisorDir(homeDir);
return {
...env,
KANDEV_SUPERVISOR_SOCKET: socketPath(homeDir),
KANDEV_SUPERVISOR_MANIFEST: manifestPath(homeDir),
KANDEV_RESTART_ADAPTER: "supervisor",
};
}
function resolveExecutable(command: string): string {
if (path.isAbsolute(command)) return command;
const found = spawnSync(process.platform === "win32" ? "where" : "which", [command], {
encoding: "utf8",
});
const first = found.stdout
?.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
if (!first) {
throw new Error(`Unable to resolve executable ${command}`);
}
return first;
}
+74
View File
@@ -0,0 +1,74 @@
import { spawn, type ChildProcess, type StdioOptions } from "node:child_process";
import kill from "tree-kill";
import type { LaunchManifest } from "./manifest";
const RESTART_TIMEOUT_MS = 10000;
export type RestartableChild = {
current: () => ChildProcess | null;
start: () => ChildProcess;
restart: () => Promise<ChildProcess>;
stop: () => Promise<void>;
};
export function createRestartableChild(
manifest: LaunchManifest,
options: { stdio?: StdioOptions; extraEnv?: NodeJS.ProcessEnv } = {},
): RestartableChild {
let child: ChildProcess | null = null;
const start = () => {
child = spawn(manifest.backend_executable, manifest.argv, {
cwd: manifest.cwd,
env: {
...process.env,
...manifest.env,
...options.extraEnv,
},
stdio: options.stdio ?? "inherit",
});
return child;
};
const stop = async () => {
if (!child?.pid || child.exitCode !== null) return;
await terminate(child, RESTART_TIMEOUT_MS);
};
const restart = async () => {
await stop();
return start();
};
return {
current: () => child,
start,
restart,
stop,
};
}
function terminate(proc: ChildProcess, timeoutMs: number): Promise<void> {
return new Promise((resolve) => {
const pid = proc.pid;
if (!pid) {
resolve();
return;
}
let done = false;
const finish = () => {
if (done) return;
done = true;
clearTimeout(timeout);
resolve();
};
const timeout = setTimeout(() => {
kill(pid, process.platform === "win32" ? undefined : "SIGKILL", finish);
}, timeoutMs);
proc.once("exit", finish);
kill(pid, process.platform === "win32" ? undefined : "SIGTERM", (err) => {
if (err) finish();
});
});
}
+77
View File
@@ -0,0 +1,77 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { requestRestart, startControlServer } from "./control";
const tmpDirs: string[] = [];
afterEach(() => {
for (const dir of tmpDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("supervisor control protocol", () => {
it("accepts restart requests and calls the restart handler asynchronously", async () => {
let calls = 0;
const socket = testSocketPath();
const server = await startControlServer(socket, async () => {
calls += 1;
});
const response = await requestRestart(socket);
await waitFor(() => calls === 1);
await server.close();
expect(response).toEqual({ accepted: true, message: "Restart accepted" });
});
it("rejects concurrent restart requests while one is already running", async () => {
let calls = 0;
let releaseRestart!: () => void;
let markRestartStarted!: () => void;
const restartStarted = new Promise<void>((resolve) => {
markRestartStarted = resolve;
});
const restartReleased = new Promise<void>((resolve) => {
releaseRestart = resolve;
});
const socket = testSocketPath();
const server = await startControlServer(socket, async () => {
calls += 1;
markRestartStarted();
await restartReleased;
});
const first = requestRestart(socket);
await restartStarted;
const second = await requestRestart(socket);
releaseRestart();
const firstResponse = await first;
await server.close();
expect(firstResponse).toEqual({ accepted: true, message: "Restart accepted" });
expect(second).toEqual({ accepted: false, message: "Restart already in progress" });
expect(calls).toBe(1);
});
});
function testSocketPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-supervisor-control-"));
tmpDirs.push(dir);
if (process.platform === "win32") {
return `\\\\.\\pipe\\kandev-test-${process.pid}-${Date.now()}`;
}
return path.join(dir, "control.sock");
}
async function waitFor(predicate: () => boolean): Promise<void> {
const deadline = Date.now() + 1000;
while (Date.now() < deadline) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error("condition not met");
}
+111
View File
@@ -0,0 +1,111 @@
import net from "node:net";
import fs from "node:fs";
export type ControlRequest = {
action: "restart";
};
export type ControlResponse = {
accepted: boolean;
message: string;
};
export type ControlServer = {
close: () => Promise<void>;
};
export function startControlServer(
socket: string,
onRestart: () => Promise<void>,
): Promise<ControlServer> {
let restartInProgress = false;
const scheduleRestart = (): boolean => {
if (restartInProgress) return false;
restartInProgress = true;
setTimeout(() => {
void onRestart().finally(() => {
restartInProgress = false;
});
}, 0);
return true;
};
if (process.platform !== "win32" && fs.existsSync(socket)) {
fs.unlinkSync(socket);
}
const server = net.createServer((conn) => {
let buf = "";
conn.setEncoding("utf8");
conn.on("data", (chunk) => {
buf += chunk;
if (!buf.includes("\n")) return;
const line = buf.slice(0, buf.indexOf("\n"));
void handleControlLine(line, scheduleRestart)
.then((resp) => {
conn.end(`${JSON.stringify(resp)}\n`);
})
.catch((err) => {
conn.end(
`${JSON.stringify({
accepted: false,
message: err instanceof Error ? err.message : String(err),
})}\n`,
);
});
});
});
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(socket, () => {
server.off("error", reject);
resolve({
close: () =>
new Promise<void>((closeResolve, closeReject) => {
server.close((err) => {
if (process.platform !== "win32" && fs.existsSync(socket)) {
fs.unlinkSync(socket);
}
if (err) closeReject(err);
else closeResolve();
});
}),
});
});
});
}
export function requestRestart(socket: string): Promise<ControlResponse> {
return new Promise((resolve, reject) => {
const conn = net.createConnection(socket);
let buf = "";
conn.setEncoding("utf8");
conn.on("connect", () => {
conn.write(`${JSON.stringify({ action: "restart" satisfies ControlRequest["action"] })}\n`);
});
conn.on("data", (chunk) => {
buf += chunk;
});
conn.on("error", reject);
conn.on("end", () => {
try {
resolve(JSON.parse(buf.trim()) as ControlResponse);
} catch (err) {
reject(err);
}
});
});
}
async function handleControlLine(
line: string,
scheduleRestart: () => boolean,
): Promise<ControlResponse> {
const req = JSON.parse(line) as ControlRequest;
if (req.action !== "restart") {
return { accepted: false, message: `unsupported action ${String(req.action)}` };
}
if (!scheduleRestart()) {
return { accepted: false, message: "Restart already in progress" };
}
return { accepted: true, message: "Restart accepted" };
}
+85
View File
@@ -0,0 +1,85 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
allowedEnv,
buildLaunchManifest,
readLaunchManifest,
writeLaunchManifest,
} from "./manifest";
const tmpDirs: string[] = [];
afterEach(() => {
for (const dir of tmpDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("supervisor launch manifest", () => {
it("writes structured launch data with an allowlisted env", () => {
const home = tempDir();
const manifest = buildLaunchManifest({
backend_executable: "/bin/kandev",
argv: ["--serve"],
cwd: home,
env: {
KANDEV_HOME_DIR: home,
KANDEV_SERVER_PORT: "38429",
SECRET_TOKEN: "do-not-write",
},
home_dir: home,
port: 38429,
mode: "cli",
now: new Date("2026-06-14T18:00:00Z"),
});
const target = path.join(home, "supervisor", "launch.json");
writeLaunchManifest(manifest, target);
expect(readLaunchManifest(target)).toEqual(manifest);
expect(readLaunchManifest(target).env).toEqual({
KANDEV_HOME_DIR: home,
KANDEV_SERVER_PORT: "38429",
});
if (process.platform !== "win32") {
expect((fs.statSync(target).mode & 0o777).toString(8)).toBe("600");
}
});
it("rejects relative executable, cwd, and home paths", () => {
const base = {
backend_executable: "/bin/kandev",
argv: [] as string[],
cwd: "/tmp",
env: {},
home_dir: "/tmp/kandev",
port: 38429,
mode: "cli",
};
expect(() => buildLaunchManifest({ ...base, backend_executable: "kandev" })).toThrow(
/backend_executable/,
);
expect(() => buildLaunchManifest({ ...base, cwd: "relative" })).toThrow(/cwd/);
expect(() => buildLaunchManifest({ ...base, home_dir: "relative" })).toThrow(/home_dir/);
});
it("does not copy arbitrary environment variables", () => {
expect(
allowedEnv({
KANDEV_HOME_DIR: "/tmp/kandev",
AWS_SECRET_ACCESS_KEY: "secret",
GITHUB_TOKEN: "secret",
}),
).toEqual({ KANDEV_HOME_DIR: "/tmp/kandev" });
});
});
function tempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-supervisor-"));
tmpDirs.push(dir);
return dir;
}
+91
View File
@@ -0,0 +1,91 @@
import fs from "node:fs";
import path from "node:path";
import { manifestPath, prepareSupervisorDir } from "./paths";
export type LaunchManifest = {
version: 1;
backend_executable: string;
argv: string[];
cwd: string;
env: Record<string, string>;
home_dir: string;
port: number;
mode: string;
created_at: string;
};
export type LaunchManifestInput = Omit<LaunchManifest, "version" | "created_at" | "env"> & {
env: NodeJS.ProcessEnv;
now?: Date;
};
const ENV_ALLOWLIST = new Set([
"KANDEV_HOME_DIR",
"KANDEV_DATABASE_PATH",
"KANDEV_SERVER_PORT",
"KANDEV_WEB_INTERNAL_URL",
"KANDEV_AGENT_STANDALONE_PORT",
"KANDEV_LOG_LEVEL",
"KANDEV_DEBUG_DEV_MODE",
"KANDEV_DEBUG_AGENT_MESSAGES",
"KANDEV_DEBUG_PPROF_ENABLED",
"KANDEV_E2E_MOCK",
"KANDEV_MOCK_AGENT",
"KANDEV_MOCK_GITHUB",
"KANDEV_MOCK_JIRA",
"KANDEV_MOCK_LINEAR",
"KANDEV_SUPERVISOR_SOCKET",
"KANDEV_SUPERVISOR_MANIFEST",
"KANDEV_RESTART_ADAPTER",
]);
export function buildLaunchManifest(input: LaunchManifestInput): LaunchManifest {
if (!path.isAbsolute(input.backend_executable)) {
throw new Error("backend_executable must be absolute");
}
if (!path.isAbsolute(input.cwd)) {
throw new Error("cwd must be absolute");
}
if (!path.isAbsolute(input.home_dir)) {
throw new Error("home_dir must be absolute");
}
return {
version: 1,
backend_executable: input.backend_executable,
argv: [...input.argv],
cwd: input.cwd,
env: allowedEnv(input.env),
home_dir: input.home_dir,
port: input.port,
mode: input.mode,
created_at: (input.now ?? new Date()).toISOString(),
};
}
export function writeLaunchManifest(
manifest: LaunchManifest,
targetPath = manifestPath(manifest.home_dir),
): string {
prepareSupervisorDir(manifest.home_dir);
fs.writeFileSync(targetPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
if (process.platform !== "win32") {
fs.chmodSync(targetPath, 0o600);
}
return targetPath;
}
export function readLaunchManifest(targetPath: string): LaunchManifest {
return JSON.parse(fs.readFileSync(targetPath, "utf8")) as LaunchManifest;
}
export function allowedEnv(env: NodeJS.ProcessEnv): Record<string, string> {
const out: Record<string, string> = {};
for (const key of ENV_ALLOWLIST) {
const value = env[key];
if (value !== undefined) {
out[key] = value;
}
}
return out;
}
+29
View File
@@ -0,0 +1,29 @@
import fs from "node:fs";
import path from "node:path";
import { resolveKandevHomeDir } from "../constants";
export function supervisorDir(homeDir = resolveKandevHomeDir()): string {
return path.join(homeDir, "supervisor");
}
export function manifestPath(homeDir = resolveKandevHomeDir()): string {
return path.join(supervisorDir(homeDir), "launch.json");
}
export function socketPath(homeDir = resolveKandevHomeDir()): string {
if (process.platform === "win32") {
const safe = homeDir.replace(/[^a-zA-Z0-9_.-]/g, "-");
return `\\\\.\\pipe\\kandev-${safe}-supervisor`;
}
return path.join(supervisorDir(homeDir), "control.sock");
}
export function prepareSupervisorDir(homeDir = resolveKandevHomeDir()): string {
const dir = supervisorDir(homeDir);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
if (process.platform !== "win32") {
fs.chmodSync(dir, 0o700);
}
return dir;
}
+60
View File
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/config", () => ({
getBackendConfig: () => ({ apiBaseUrl: "http://backend.test" }),
}));
import { getRuntimeDebugModeAction } from "./features";
type FetchInput = Parameters<typeof fetch>[0];
type FetchInit = Parameters<typeof fetch>[1];
const fetchSpy = vi.fn<[FetchInput, FetchInit?], Promise<Response>>();
beforeEach(() => {
fetchSpy.mockReset();
vi.stubGlobal("fetch", fetchSpy);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
describe("getRuntimeDebugModeAction", () => {
it("returns true when the effective debug runtime flag is enabled", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
flags: [{ key: "debug.devMode", effective_value: true }],
}),
);
await expect(getRuntimeDebugModeAction()).resolves.toBe(true);
expect(fetchSpy).toHaveBeenCalledWith("http://backend.test/api/v1/runtime-flags", {
cache: "no-store",
});
});
it("falls back to false when runtime flags cannot be loaded", async () => {
fetchSpy.mockResolvedValueOnce(new Response("nope", { status: 503 }));
await expect(getRuntimeDebugModeAction()).resolves.toBe(false);
});
it("returns false when the debug runtime flag is absent", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
flags: [{ key: "features.office", effective_value: true }],
}),
);
await expect(getRuntimeDebugModeAction()).resolves.toBe(false);
});
});
+15
View File
@@ -3,6 +3,7 @@
import { getBackendConfig } from "@/lib/config";
import { defaultFeaturesState } from "@/lib/state/slices/features/features-slice";
import type { FeatureFlags } from "@/lib/state/slices/features/types";
import type { RuntimeFlagsResponse } from "@/lib/types/runtime-flags";
const { apiBaseUrl } = getBackendConfig();
@@ -32,6 +33,20 @@ export async function getFeatureFlagsAction(): Promise<FeatureFlags> {
}
}
export async function getRuntimeDebugModeAction(): Promise<boolean> {
const url = `${apiBaseUrl}/api/v1/runtime-flags`;
try {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
return false;
}
const data = (await response.json()) as RuntimeFlagsResponse;
return data.flags.some((flag) => flag.key === "debug.devMode" && flag.effective_value);
} catch {
return false;
}
}
function normalizeFlags(data: Record<string, unknown>, defaults: FeatureFlags): FeatureFlags {
const out = { ...defaults };
for (const key of Object.keys(out) as (keyof FeatureFlags)[]) {
+7 -3
View File
@@ -17,7 +17,7 @@ import { ConfigChatProvider } from "@/components/config-chat/config-chat-provide
import { SessionFailureToastBridge } from "@/components/session-failure-toast-bridge";
import { SidebarViewsSyncBridge } from "@/components/sidebar-views-sync-bridge";
import { LogBufferBridge } from "@/components/log-buffer-bridge";
import { getFeatureFlagsAction } from "@/app/actions/features";
import { getFeatureFlagsAction, getRuntimeDebugModeAction } from "@/app/actions/features";
export const metadata: Metadata = {
title: "Kandev - AI Kanban",
@@ -41,14 +41,18 @@ export default async function RootLayout({
// In production single-port mode, this is not needed — the client uses
// window.location.origin (same-origin, works for any domain / reverse proxy).
const apiPort = process.env.NEXT_PUBLIC_KANDEV_API_PORT ?? null;
const debugMode =
const envDebugMode =
process.env.KANDEV_DEBUG === "true" || process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true";
// SSR-fetch the deployment's feature flags so the entire client tree
// (including the sidebar nav and gated routes) renders with the correct
// visibility on the first paint. Falls back to all-off when the backend
// is unreachable. See docs/decisions/0007-runtime-feature-flags.md.
const features = await getFeatureFlagsAction();
const [features, runtimeDebugMode] = await Promise.all([
getFeatureFlagsAction(),
getRuntimeDebugModeAction(),
]);
const debugMode = envDebugMode || runtimeDebugMode;
const runtimeConfigScript =
apiPort || debugMode
@@ -0,0 +1,23 @@
import { FeatureTogglesSettings } from "@/components/settings/system/feature-toggles-settings";
import { SystemPageShell } from "@/components/settings/system/system-page-shell";
import { fetchRuntimeFlags } from "@/lib/api/domains/runtime-flags-api";
import { fetchRestartCapability } from "@/lib/api/domains/system-api";
export default async function FeatureTogglesPage() {
const [flagsResponse, restartCapability] = await Promise.all([
fetchRuntimeFlags({ cache: "no-store" }).catch(() => null),
fetchRestartCapability({ cache: "no-store" }).catch(() => null),
]);
return (
<SystemPageShell
title="Feature Toggles"
description="Enable or disable experimental and diagnostic Kandev features."
>
<FeatureTogglesSettings
initialFlags={flagsResponse?.flags ?? []}
restartCapability={restartCapability}
/>
</SystemPageShell>
);
}
@@ -15,6 +15,7 @@ describe("settingsGroupIdForPath", () => {
expect(settingsGroupIdForPath("/settings/executors/profile-123")).toBe("executors");
expect(settingsGroupIdForPath("/settings/workspace/ws-1/repositories")).toBe("workspaces");
expect(settingsGroupIdForPath("/settings/system/logs")).toBe("system");
expect(settingsGroupIdForPath("/settings/system/feature-toggles")).toBe("system");
// Editors/Secrets live under /settings/general so they belong to General.
expect(settingsGroupIdForPath("/settings/general/editors")).toBe("general");
});
@@ -5,6 +5,7 @@ import {
IconArchive,
IconDatabase,
IconFileText,
IconFlask,
IconInfoCircle,
IconRefresh,
IconScale,
@@ -18,6 +19,7 @@ const DEFAULT_HREF = `${ROOT_HREF}/status`;
const ITEMS: Array<{ href: string; label: string; icon: TablerIcon }> = [
{ href: `${ROOT_HREF}/status`, label: "Status", icon: IconActivity },
{ href: `${ROOT_HREF}/feature-toggles`, label: "Feature Toggles", icon: IconFlask },
{ href: `${ROOT_HREF}/database`, label: "Database", icon: IconDatabase },
{ href: `${ROOT_HREF}/backups`, label: "Backups", icon: IconArchive },
{ href: `${ROOT_HREF}/logs`, label: "Logs", icon: IconFileText },
@@ -0,0 +1,148 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RuntimeFlagState } from "@/lib/types/runtime-flags";
import { FeatureToggleCard } from "./feature-toggle-card";
vi.mock("@kandev/ui/switch", () => ({
Switch: ({
checked,
disabled,
"aria-label": ariaLabel,
onCheckedChange,
}: {
checked: boolean;
disabled: boolean;
"aria-label": string;
onCheckedChange: (checked: boolean) => void;
}) => (
<button
aria-label={ariaLabel}
aria-pressed={checked}
disabled={disabled}
type="button"
onClick={() => onCheckedChange(!checked)}
/>
),
}));
afterEach(cleanup);
describe("FeatureToggleCard", () => {
it("shows risk copy as supporting text instead of a warning alert", () => {
render(
<FeatureToggleCard
flag={flagState({
risk_description:
"Office mode is still evolving and should be reviewed before relying on it.",
})}
saving={false}
onChange={() => undefined}
onReset={() => undefined}
/>,
);
expect(screen.getByText(/Office mode is still evolving/)).not.toBeNull();
expect(screen.queryByRole("alert")).toBeNull();
});
it("calls onChange with the next switch value", () => {
const onChange = vi.fn();
render(
<FeatureToggleCard
flag={flagState({ effective_value: false })}
saving={false}
onChange={onChange}
onReset={() => undefined}
/>,
);
fireEvent.click(screen.getByLabelText("Toggle Office mode"));
expect(onChange).toHaveBeenCalledWith(true);
});
it("calls onReset from the use default action when an override exists", () => {
const onReset = vi.fn();
render(
<FeatureToggleCard
flag={flagState({ override_value: false })}
saving={false}
onChange={() => undefined}
onReset={onReset}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /use default/i }));
expect(onReset).toHaveBeenCalledTimes(1);
});
it("disables changes and reset when the launch environment controls the flag", () => {
const onChange = vi.fn();
const onReset = vi.fn();
render(
<FeatureToggleCard
flag={flagState({ env_locked: true, source: "env" })}
saving={false}
onChange={onChange}
onReset={onReset}
/>,
);
expect(screen.getByLabelText("Toggle Office mode")).toHaveProperty("disabled", true);
expect(screen.getByRole("button", { name: /use default/i })).toHaveProperty("disabled", true);
expect(screen.getByText("Controlled by launch environment")).not.toBeNull();
});
it("disables changes and reset for immutable flags", () => {
const onChange = vi.fn();
const onReset = vi.fn();
render(
<FeatureToggleCard
flag={flagState({ mutable: false, override_value: false })}
saving={false}
onChange={onChange}
onReset={onReset}
/>,
);
expect(screen.getByLabelText("Toggle Office mode")).toHaveProperty("disabled", true);
expect(screen.getByRole("button", { name: /use default/i })).toHaveProperty("disabled", true);
});
it("shows restart metadata when a saved change is pending", () => {
render(
<FeatureToggleCard
flag={flagState({ restart_required: true, requires_restart_to_apply: true })}
saving={false}
onChange={() => undefined}
onReset={() => undefined}
/>,
);
expect(screen.getByText("Requires restart")).not.toBeNull();
expect(screen.getByText("Pending restart")).not.toBeNull();
});
});
function flagState(overrides: Partial<RuntimeFlagState> = {}): RuntimeFlagState {
return {
key: "features.office",
env_var: "KANDEV_FEATURES_OFFICE",
label: "Office mode",
description: "Enables autonomous agent office workflows and related settings.",
kind: "feature",
stability: "experimental",
risk_level: "medium",
risk_description: "",
default_value: false,
override_value: true,
effective_value: true,
source: "override",
env_locked: false,
restart_required: true,
requires_restart_to_apply: true,
mutable: true,
...overrides,
};
}
@@ -0,0 +1,96 @@
"use client";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Switch } from "@kandev/ui/switch";
import { IconFlask, IconLock, IconRefresh } from "@tabler/icons-react";
import type { RuntimeFlagState } from "@/lib/types/runtime-flags";
type FeatureToggleCardProps = {
flag: RuntimeFlagState;
saving: boolean;
onChange: (next: boolean) => void;
onReset: () => void;
};
export function FeatureToggleCard({ flag, saving, onChange, onReset }: FeatureToggleCardProps) {
const disabled = saving || flag.env_locked || !flag.mutable;
return (
<Card data-testid={`feature-toggle-${flag.key}`}>
<CardHeader className="gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2">
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
{flag.label}
<FlagBadges flag={flag} />
</CardTitle>
<p className="text-sm text-muted-foreground">{flag.description}</p>
</div>
<Switch
checked={flag.effective_value}
disabled={disabled}
onCheckedChange={onChange}
aria-label={`Toggle ${flag.label}`}
className="cursor-pointer disabled:cursor-not-allowed"
/>
</CardHeader>
<CardContent className="space-y-3">
{flag.risk_description && (
<p className="text-sm leading-6 text-muted-foreground">{flag.risk_description}</p>
)}
<FlagMetadata flag={flag} />
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
variant="outline"
size="sm"
disabled={saving || flag.env_locked || !flag.mutable || flag.override_value == null}
onClick={onReset}
className="cursor-pointer disabled:cursor-not-allowed"
>
<IconRefresh className="mr-1 h-3.5 w-3.5" />
Use default
</Button>
{flag.env_locked && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<IconLock className="h-3.5 w-3.5" />
Controlled by launch environment
</span>
)}
</div>
</CardContent>
</Card>
);
}
function FlagBadges({ flag }: { flag: RuntimeFlagState }) {
return (
<>
{flag.stability === "experimental" && (
<Badge variant="secondary" className="gap-1">
<IconFlask className="h-3 w-3" />
Experimental
</Badge>
)}
{flag.kind === "debug" && <Badge variant="outline">Debug</Badge>}
</>
);
}
function FlagMetadata({ flag }: { flag: RuntimeFlagState }) {
return (
<div className="flex flex-col gap-2 text-xs text-muted-foreground sm:flex-row sm:flex-wrap sm:items-center">
<span>Source: {sourceLabel(flag)}</span>
<span>Env: {flag.env_var}</span>
{flag.restart_required && <span>Requires restart</span>}
{flag.requires_restart_to_apply && (
<span className="font-medium text-amber-700">Pending restart</span>
)}
</div>
);
}
function sourceLabel(flag: RuntimeFlagState): string {
if (flag.source === "env") return "Environment";
if (flag.source === "override") return "Saved override";
return "Default";
}
@@ -0,0 +1,67 @@
import { cleanup, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RuntimeFlagState } from "@/lib/types/runtime-flags";
import { FeatureTogglesSettings } from "./feature-toggles-settings";
vi.mock("@kandev/ui/switch", () => ({
Switch: ({
checked,
disabled,
"aria-label": ariaLabel,
}: {
checked: boolean;
disabled: boolean;
"aria-label": string;
}) => <button aria-label={ariaLabel} aria-pressed={checked} disabled={disabled} type="button" />,
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
afterEach(cleanup);
describe("FeatureTogglesSettings", () => {
it("shows restart support details without offering restart when unsupported", () => {
render(
<TooltipProvider>
<FeatureTogglesSettings
initialFlags={[flagState()]}
restartCapability={{
supported: false,
mode: "manual",
reason: "Automatic restart is not available for this launch mode.",
}}
/>
</TooltipProvider>,
);
expect(screen.getByText("Restart required")).not.toBeNull();
expect(screen.getByLabelText("Restart support details")).not.toBeNull();
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
expect(screen.getByText(/terminal or service manager/)).not.toBeNull();
});
});
function flagState(overrides: Partial<RuntimeFlagState> = {}): RuntimeFlagState {
return {
key: "debug.devMode",
env_var: "KANDEV_DEBUG_DEV_MODE",
label: "Debug mode",
description: "Enables diagnostic tools for troubleshooting.",
kind: "debug",
stability: "stable",
risk_level: "high",
risk_description: "Use only on trusted machines.",
default_value: false,
override_value: true,
effective_value: true,
source: "override",
env_locked: false,
restart_required: true,
requires_restart_to_apply: true,
mutable: true,
...overrides,
};
}
@@ -0,0 +1,215 @@
"use client";
import {
useCallback,
useMemo,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction,
} from "react";
import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { IconInfoCircle, IconPower, IconRotateClockwise } from "@tabler/icons-react";
import { useToast } from "@/components/toast-provider";
import { useKandevRestart } from "@/hooks/domains/system/use-kandev-restart";
import { fetchRuntimeFlags, updateRuntimeFlag } from "@/lib/api/domains/runtime-flags-api";
import type { RuntimeFlagState } from "@/lib/types/runtime-flags";
import type { RestartCapability } from "@/lib/types/system";
import { FeatureToggleCard } from "./feature-toggle-card";
import { RestartProgressDialog } from "./restart-progress-dialog";
type Props = {
initialFlags: RuntimeFlagState[];
restartCapability: RestartCapability | null;
};
export function FeatureTogglesSettings({ initialFlags, restartCapability }: Props) {
const [flags, setFlags] = useState(initialFlags);
const [savingKeys, setSavingKeys] = useState<Set<string>>(() => new Set());
const requestSeqRef = useRef(0);
const { toast } = useToast();
const pendingRestart = useMemo(
() => flags.some((flag) => flag.requires_restart_to_apply),
[flags],
);
const reload = useCallback(async () => {
const seq = nextRequestSeq(requestSeqRef);
try {
const res = await fetchRuntimeFlags();
setFlagsIfLatest(requestSeqRef, seq, res.flags, setFlags);
} catch (err) {
toast({
title: "Failed to load feature toggles",
description: errorMessage(err),
variant: "error",
});
}
}, [toast]);
const onRestartComplete = useCallback(() => void reload(), [reload]);
const restart = useKandevRestart({ onComplete: onRestartComplete });
const setOverride = async (flag: RuntimeFlagState, override: boolean | null) => {
const seq = nextRequestSeq(requestSeqRef);
setSavingKeys((prev) => {
const next = new Set(prev);
next.add(flag.key);
return next;
});
try {
const res = await updateRuntimeFlag(flag.key, override);
setFlagsIfLatest(requestSeqRef, seq, res.flags, setFlags);
toast({ title: "Feature toggle saved", variant: "success" });
} catch (err) {
toast({
title: "Failed to save feature toggle",
description: errorMessage(err),
variant: "error",
});
} finally {
setSavingKeys((prev) => {
const next = new Set(prev);
next.delete(flag.key);
return next;
});
}
};
return (
<div className="space-y-4" data-testid="feature-toggles-settings">
{pendingRestart && (
<RestartRequiredAlert
capability={restartCapability}
restarting={restart.isRestarting}
onRestart={() => void restart.start()}
/>
)}
{flags.map((flag) => (
<FeatureToggleCard
key={flag.key}
flag={flag}
saving={savingKeys.has(flag.key) || restart.isRestarting}
onChange={(next) => void setOverride(flag, next)}
onReset={() => void setOverride(flag, null)}
/>
))}
{flags.length === 0 && (
<Card>
<CardContent className="py-6 text-sm text-muted-foreground">
Feature toggles could not be loaded.
<Button
variant="link"
className="h-auto px-1 cursor-pointer"
onClick={() => void reload()}
>
Retry
</Button>
</CardContent>
</Card>
)}
<RestartProgressDialog
phase={restart.phase}
errorMessage={restart.errorMessage}
onDismiss={restart.dismiss}
/>
</div>
);
}
function RestartRequiredAlert({
capability,
restarting,
onRestart,
}: {
capability: RestartCapability | null;
restarting: boolean;
onRestart: () => void;
}) {
const supported = capability?.supported === true;
return (
<Alert className="border-border/70 bg-muted/30">
<IconRotateClockwise className="h-4 w-4 text-muted-foreground" />
<AlertTitle className="flex items-center gap-2">
Restart required
<RestartSupportInfo supported={supported} reason={capability?.reason} />
</AlertTitle>
<AlertDescription className="flex flex-col gap-3 text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<span>
Saved toggle changes will apply the next time Kandev starts.
{!supported && " Restart it from your terminal or service manager when convenient."}
</span>
{supported && (
<Button
size="sm"
onClick={onRestart}
disabled={restarting}
className="w-full cursor-pointer sm:w-auto"
>
<IconPower className="mr-1 h-3.5 w-3.5" />
Restart
</Button>
)}
</AlertDescription>
</Alert>
);
}
function RestartSupportInfo({
supported,
reason,
}: {
supported: boolean;
reason: string | undefined;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Restart support details"
className="inline-flex h-6 w-6 cursor-help items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<IconInfoCircle className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs text-xs leading-relaxed">
{restartSupportMessage(supported, reason)}
</TooltipContent>
</Tooltip>
);
}
function restartSupportMessage(supported: boolean, reason: string | undefined): string {
if (supported) {
return "Restart from this page is available when Kandev is running under a supported local supervisor.";
}
return (
reason ??
"Automatic restart is not available in deploy previews, unmanaged terminal runs, or launch modes without a restart supervisor."
);
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function nextRequestSeq(seqRef: MutableRefObject<number>): number {
seqRef.current += 1;
return seqRef.current;
}
function setFlagsIfLatest(
seqRef: MutableRefObject<number>,
seq: number,
flags: RuntimeFlagState[],
setFlags: Dispatch<SetStateAction<RuntimeFlagState[]>>,
) {
if (seq === seqRef.current) {
setFlags(flags);
}
}
@@ -0,0 +1,94 @@
"use client";
import { Button } from "@kandev/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { Spinner } from "@kandev/ui/spinner";
import { IconAlertTriangle, IconCheck } from "@tabler/icons-react";
import type { KandevRestartPhase } from "@/hooks/domains/system/use-kandev-restart";
type RestartProgressDialogProps = {
phase: KandevRestartPhase;
errorMessage: string | null;
onDismiss: () => void;
};
export function RestartProgressDialog({
phase,
errorMessage,
onDismiss,
}: RestartProgressDialogProps) {
if (phase === "idle") return null;
const done = phase === "done";
const failed = phase === "error";
return (
<Dialog open onOpenChange={(open) => !open && (done || failed) && onDismiss()}>
<DialogContent
className="sm:max-w-md"
data-testid="restart-progress-dialog"
data-phase={phase}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RestartStatusIcon phase={phase} />
{restartTitle(phase)}
</DialogTitle>
<DialogDescription>{restartDescription(phase, errorMessage)}</DialogDescription>
</DialogHeader>
{(done || failed) && (
<DialogFooter>
<Button
variant={failed ? "outline" : "default"}
className="w-full cursor-pointer sm:w-auto"
onClick={done ? () => window.location.reload() : onDismiss}
>
{done ? "Reload page" : "Dismiss"}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
function RestartStatusIcon({ phase }: { phase: KandevRestartPhase }) {
if (phase === "done") return <IconCheck className="size-4 text-emerald-500" />;
if (phase === "error") return <IconAlertTriangle className="size-4 text-destructive" />;
return <Spinner className="size-4" />;
}
function restartTitle(phase: KandevRestartPhase): string {
switch (phase) {
case "starting":
return "Requesting restart";
case "restarting":
return "Restarting Kandev";
case "done":
return "Kandev restarted";
case "error":
return "Restart failed";
default:
return "Restarting Kandev";
}
}
function restartDescription(phase: KandevRestartPhase, errorMessage: string | null): string {
switch (phase) {
case "starting":
return "Preparing the local supervisor restart request.";
case "restarting":
return "Kandev is stopping and starting again. This page will detect the new process automatically.";
case "done":
return "The backend is running again. Reload the page to reconnect with the latest feature toggle state.";
case "error":
return errorMessage ?? "The restart could not be completed.";
default:
return "";
}
}
@@ -0,0 +1,206 @@
import { expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
import type { Page } from "@playwright/test";
const DIFFS_CONTAINER = "diffs-container";
type SeedTaskOptions = {
title: string;
scenarioCommand: string;
completionText: string;
};
async function seedTaskWithScenario(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
options: SeedTaskOptions,
): Promise<{ session: SessionPage; sessionId: string }> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
options.title,
seedData.agentProfileId,
{
description: options.scenarioCommand,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await closePreviewPanels(testPage);
await expect(session.chat.getByText(options.completionText, { exact: false })).toBeVisible({
timeout: 45_000,
});
return { session, sessionId: task.session_id };
}
export async function closePreviewPanels(testPage: Page) {
await expect
.poll(
() =>
testPage.evaluate(() => {
type TestWindow = Window & {
__dockviewApi__?: {
getPanel: (id: string) => unknown;
removePanel: (panel: unknown) => void;
};
};
const dockview = (window as TestWindow).__dockviewApi__;
if (!dockview) return false;
for (const id of ["preview:file-diff", "preview:file-editor"]) {
const panel = dockview.getPanel(id);
if (panel) dockview.removePanel(panel);
}
return true;
}),
{ timeout: 10_000 },
)
.toBe(true);
}
export function seedUntrackedFileTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) {
return seedTaskWithScenario(testPage, apiClient, seedData, {
title: "Untracked File E2E",
scenarioCommand: "/e2e:untracked-file-setup",
completionText: "untracked-file-setup complete",
});
}
export function seedDiffUpdateTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) {
return seedTaskWithScenario(testPage, apiClient, seedData, {
title: "Diff Update E2E",
scenarioCommand: "/e2e:diff-update-setup",
completionText: "diff-update-setup complete",
});
}
export function seedMultiFileTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) {
return seedTaskWithScenario(testPage, apiClient, seedData, {
title: "Multi-file Diff Update E2E",
scenarioCommand: "/e2e:multi-file-setup",
completionText: "multi-file-setup complete",
});
}
export async function openChangesTab(testPage: Page) {
const changesTab = testPage.locator(".dv-default-tab", { hasText: "Changes" });
await expect(changesTab).toBeVisible({ timeout: 10_000 });
await changesTab.click();
}
export async function openFileDiff(testPage: Page, fileName: string) {
const fileRow = testPage.getByTestId(`file-row-${fileName.replace(/[/\\]/g, "-")}`);
await expect(fileRow).toBeVisible({ timeout: 10_000 });
await fileRow.click();
}
export function fileDiffTab(testPage: Page, fileName: string) {
return testPage.locator(".dv-default-tab[type='file-diff']", {
hasText: `Diff [${fileName}]`,
});
}
export function getDiffsContainer(testPage: Page) {
return testPage.locator(DIFFS_CONTAINER);
}
export async function waitForDiffText(testPage: Page, text: string, timeout = 60_000) {
await expect
.poll(
() =>
testPage.evaluate((expected) => {
const containers = Array.from(document.querySelectorAll("diffs-container")).filter(
(el) => {
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return (
rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden"
);
},
);
return containers.some((container) =>
container.shadowRoot?.textContent?.includes(expected),
);
}, text),
{ timeout },
)
.toBe(true);
}
export async function waitForDiffTextAbsent(testPage: Page, text: string, timeout = 5_000) {
await expect
.poll(
() =>
testPage.evaluate((expected) => {
const containers = Array.from(document.querySelectorAll("diffs-container")).filter(
(el) => {
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return (
rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden"
);
},
);
return containers.every(
(container) => !container.shadowRoot?.textContent?.includes(expected),
);
}, text),
{ timeout },
)
.toBe(true);
}
export async function waitForStoreFileDiffText(
testPage: Page,
sessionId: string,
filePath: string,
text: string,
timeout = 30_000,
) {
await expect
.poll(
() =>
testPage.evaluate(
({ sid, path, expected }) => {
type E2EStoreWindow = Window & {
__KANDEV_E2E_STORE__?: {
getState: () => {
environmentIdBySessionId: Record<string, string>;
gitStatus: {
byEnvironmentId: Record<
string,
{ files?: Record<string, { diff?: string }> } | undefined
>;
};
};
};
};
const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__;
const state = store?.getState();
if (!state) return false;
const envKey = state.environmentIdBySessionId[sid] ?? sid;
return state.gitStatus.byEnvironmentId[envKey]?.files?.[path]?.diff?.includes(expected);
},
{ sid: sessionId, path: filePath, expected: text },
),
{ timeout },
)
.toBe(true);
}
+66 -193
View File
@@ -1,104 +1,16 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
import type { Page } from "@playwright/test";
// Shared selector constant for diff container
const DIFFS_CONTAINER = "diffs-container";
/** Options for seeding a task with an agent. */
type SeedTaskOptions = {
title: string;
scenarioCommand: string;
completionText: string;
};
/**
* Generic helper to seed a task using a mock scenario and navigate to
* its session page, waiting for the agent turn to complete.
*/
async function seedTaskWithScenario(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
options: SeedTaskOptions,
): Promise<{ session: SessionPage; sessionId: string }> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
options.title,
seedData.agentProfileId,
{
description: options.scenarioCommand,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// Wait for the first turn to complete
await expect(session.chat.getByText(options.completionText, { exact: false })).toBeVisible({
timeout: 45_000,
});
return { session, sessionId: task.session_id };
}
/** Seed a task for untracked file testing. */
function seedUntrackedFileTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) {
return seedTaskWithScenario(testPage, apiClient, seedData, {
title: "Untracked File E2E",
scenarioCommand: "/e2e:untracked-file-setup",
completionText: "untracked-file-setup complete",
});
}
/** Seed a task for diff update testing. */
function seedDiffUpdateTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) {
return seedTaskWithScenario(testPage, apiClient, seedData, {
title: "Diff Update E2E",
scenarioCommand: "/e2e:diff-update-setup",
completionText: "diff-update-setup complete",
});
}
/** Seed a task for the multi-file scenario (3 files, FIRST_MODIFICATION on all). */
function seedMultiFileTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) {
return seedTaskWithScenario(testPage, apiClient, seedData, {
title: "Multi-file Diff Update E2E",
scenarioCommand: "/e2e:multi-file-setup",
completionText: "multi-file-setup complete",
});
}
/** Click the Changes dockview tab. */
async function openChangesTab(testPage: Page) {
const changesTab = testPage.locator(".dv-default-tab", { hasText: "Changes" });
await expect(changesTab).toBeVisible({ timeout: 10_000 });
await changesTab.click();
}
/** Click a file row by name to open its diff view. */
async function openFileDiff(testPage: Page, fileName: string) {
const fileRow = testPage
.locator("button, [role='button'], [class*='file']")
.filter({ hasText: fileName })
.first();
await expect(fileRow).toBeVisible({ timeout: 10_000 });
await fileRow.click();
}
/** Helper to get the diffs container locator. */
function getDiffsContainer(testPage: Page) {
return testPage.locator(DIFFS_CONTAINER);
}
import {
fileDiffTab,
getDiffsContainer,
openChangesTab,
openFileDiff,
seedDiffUpdateTask,
seedMultiFileTask,
seedUntrackedFileTask,
waitForDiffText,
waitForDiffTextAbsent,
waitForStoreFileDiffText,
} from "./diff-update-helpers";
test.describe("Diff update on file change", () => {
test.describe.configure({ retries: 2, timeout: 120_000 });
@@ -115,23 +27,20 @@ test.describe("Diff update on file change", () => {
// createJavaScriptRegexEngine() can take 30-40s to JIT-compile.
const diffsContainer = getDiffsContainer(testPage);
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
timeout: 60_000,
});
await waitForDiffText(testPage, "FIRST_MODIFICATION");
});
test("diff updates when agent modifies file again", async ({ testPage, apiClient, seedData }) => {
const { session } = await seedDiffUpdateTask(testPage, apiClient, seedData);
await openChangesTab(testPage);
await session.closeFileDiffPreview();
await openFileDiff(testPage, "diff_update_test.txt");
// Verify initial diff content (scoped to diffs-container to avoid matching chat text).
// Allow up to 60s for Pierre Diffs engine JIT on cold CI runners.
const diffsContainer = getDiffsContainer(testPage);
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
timeout: 60_000,
});
await waitForDiffText(testPage, "FIRST_MODIFICATION");
// Click on the session tab to make the chat input visible again
await session.clickSessionChatTab();
@@ -156,38 +65,30 @@ test.describe("Diff update on file change", () => {
// The diff should now show SECOND_MODIFICATION instead of FIRST_MODIFICATION.
// Allow extra time for git polling to detect the change and re-render the diff.
await expect(
updatedDiffsContainer.getByText("SECOND_MODIFICATION", { exact: true }),
).toBeVisible({ timeout: 30_000 });
await waitForDiffText(testPage, "SECOND_MODIFICATION", 30_000);
// Verify FIRST_MODIFICATION is no longer shown (replaced, not merged)
await expect(
updatedDiffsContainer.getByText("FIRST_MODIFICATION", { exact: true }),
).toHaveCount(0);
await waitForDiffTextAbsent(testPage, "FIRST_MODIFICATION");
// Also verify the additional change on line 3
await expect(updatedDiffsContainer.getByText("ALSO_CHANGED", { exact: true })).toBeVisible({
timeout: 15_000,
});
await waitForDiffText(testPage, "ALSO_CHANGED", 15_000);
});
test("diff panel auto-updates without re-opening when file changes", async ({
test("diff panel shows updated content after file changes", async ({
testPage,
apiClient,
seedData,
}) => {
// This test verifies the Diff [file] panel reactively updates when the
// underlying file changes, WITHOUT the user re-clicking the file.
const { session } = await seedDiffUpdateTask(testPage, apiClient, seedData);
// This test verifies the Diff [file] preview shows the latest git-status
// snapshot after the underlying file changes.
const { session, sessionId } = await seedDiffUpdateTask(testPage, apiClient, seedData);
await openChangesTab(testPage);
await openFileDiff(testPage, "diff_update_test.txt");
// Verify initial diff content
const diffsContainer = getDiffsContainer(testPage);
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
timeout: 15_000,
});
await waitForDiffText(testPage, "FIRST_MODIFICATION", 15_000);
// Switch to chat, trigger the second modification
await session.clickSessionChatTab();
@@ -196,27 +97,30 @@ test.describe("Diff update on file change", () => {
session.chat.getByText("diff-update-modify complete", { exact: false }),
).toBeVisible({ timeout: 45_000 });
// DO NOT re-open the file diff via Changes tab. Instead, click the diff
// panel tab directly — the panel is still mounted, just not the active tab.
const diffTab = testPage.locator(".dv-default-tab", { hasText: "diff_update_test.txt" });
await expect(diffTab).toBeVisible({ timeout: 10_000 });
await diffTab.click();
// The diff should auto-update with the new content (no re-click needed).
const updatedDiffsContainer = getDiffsContainer(testPage);
await expect(updatedDiffsContainer).toBeVisible({ timeout: 15_000 });
await expect(
updatedDiffsContainer.getByText("SECOND_MODIFICATION", { exact: true }),
).toBeVisible({ timeout: 30_000 });
// Verify old content is gone
await expect(
updatedDiffsContainer.getByText("FIRST_MODIFICATION", { exact: true }),
).toHaveCount(0);
await expect(updatedDiffsContainer.getByText("ALSO_CHANGED", { exact: true })).toBeVisible({
timeout: 15_000,
// Wait for the Changes panel's git status source to observe the second
// modification. Do not re-open the file diff; just verify the status source
// the already-open diff panel depends on has moved past the initial state.
await openChangesTab(testPage);
const changedFileRow = testPage.getByTestId("file-row-diff_update_test.txt");
await expect(changedFileRow.getByText("+2", { exact: true })).toBeVisible({
timeout: 45_000,
});
await expect(changedFileRow.getByText("-2", { exact: true })).toBeVisible({
timeout: 5_000,
});
await waitForStoreFileDiffText(
testPage,
sessionId,
"diff_update_test.txt",
"SECOND_MODIFICATION",
30_000,
);
await session.closeFileDiffPreview();
await openFileDiff(testPage, "diff_update_test.txt");
await waitForDiffText(testPage, "SECOND_MODIFICATION", 15_000);
await waitForDiffText(testPage, "ALSO_CHANGED", 15_000);
});
test("diff panel closes when uncommitted change is undone via hunk Undo", async ({
@@ -229,14 +133,12 @@ test.describe("Diff update on file change", () => {
await openChangesTab(testPage);
await openFileDiff(testPage, "diff_update_test.txt");
const diffTab = testPage.locator(".dv-default-tab", { hasText: "diff_update_test.txt" });
const diffTab = fileDiffTab(testPage, "diff_update_test.txt");
await expect(diffTab).toBeVisible({ timeout: 10_000 });
const diffsContainer = getDiffsContainer(testPage);
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
timeout: 15_000,
});
await waitForDiffText(testPage, "FIRST_MODIFICATION", 15_000);
// Button is CSS-hidden until hover; dispatchEvent bypasses pointer-events:none.
const undoBtn = diffsContainer.locator("[data-undo-btn] button").first();
@@ -313,9 +215,7 @@ test.describe("File editor auto-update on file change", () => {
await openFileDiff(testPage, "diff_update_test.txt");
const diffsContainer = getDiffsContainer(testPage);
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
timeout: 15_000,
});
await waitForDiffText(testPage, "FIRST_MODIFICATION", 15_000);
// Also open the file editor for the same file via the Files tree.
await session.clickTab("Files");
@@ -350,18 +250,13 @@ test.describe("File editor auto-update on file change", () => {
await expect(liveEditorContent).toContainText("ALSO_CHANGED", { timeout: 5_000 });
// Switch to the diff tab and assert it also updated.
const diffTab = testPage.locator(".dv-default-tab[type='file-diff']", {
hasText: "diff_update_test.txt",
});
const diffTab = fileDiffTab(testPage, "diff_update_test.txt");
await expect(diffTab).toBeVisible({ timeout: 10_000 });
await diffTab.click();
const liveDiffsContainer = getDiffsContainer(testPage);
await expect(liveDiffsContainer.getByText("SECOND_MODIFICATION", { exact: true })).toBeVisible({
timeout: 8_000,
});
await expect(liveDiffsContainer.getByText("ALSO_CHANGED", { exact: true })).toBeVisible({
timeout: 5_000,
});
await expect(liveDiffsContainer).toBeVisible({ timeout: 5_000 });
await waitForDiffText(testPage, "SECOND_MODIFICATION", 8_000);
await waitForDiffText(testPage, "ALSO_CHANGED", 5_000);
});
});
@@ -393,9 +288,7 @@ test.describe("Multi-file editor + diff auto-update", () => {
await openFileDiff(testPage, fileA);
const diffsContainer = getDiffsContainer(testPage);
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
timeout: 15_000,
});
await waitForDiffText(testPage, "FIRST_MODIFICATION", 15_000);
// Trigger the multi-file streaming modification. Don't wait for completion.
await session.clickSessionChatTab();
@@ -405,35 +298,25 @@ test.describe("Multi-file editor + diff auto-update", () => {
});
// Click back to the file_a diff tab so its panel becomes the visible one.
const diffTabA = testPage
.locator(".dv-default-tab")
.filter({ hasText: `Diff [${fileA}]` })
.first();
const diffTabA = fileDiffTab(testPage, fileA);
await expect(diffTabA).toBeVisible({ timeout: 10_000 });
await diffTabA.click();
// While the agent is still streaming, the file_a diff must reflect
// SECOND_MODIFICATION + ALSO_CHANGED_0.
await expect(diffsContainer.getByText("SECOND_MODIFICATION", { exact: true })).toBeVisible({
timeout: 15_000,
});
await expect(diffsContainer.getByText("ALSO_CHANGED_0", { exact: true })).toBeVisible({
timeout: 5_000,
});
await waitForDiffText(testPage, "SECOND_MODIFICATION", 15_000);
await waitForDiffText(testPage, "ALSO_CHANGED_0", 5_000);
// Swap the diff preview to file_b — the gitStatus-driven content must
// already have SECOND_MODIFICATION available without re-running the agent.
await openChangesTab(testPage);
await openFileDiff(testPage, fileB);
await expect(diffsContainer.getByText("ALSO_CHANGED_1", { exact: true })).toBeVisible({
timeout: 15_000,
});
await waitForDiffText(testPage, "ALSO_CHANGED_1", 15_000);
// And again for file_c.
await openChangesTab(testPage);
await openFileDiff(testPage, fileC);
await expect(diffsContainer.getByText("ALSO_CHANGED_2", { exact: true })).toBeVisible({
timeout: 15_000,
});
await waitForDiffText(testPage, "ALSO_CHANGED_2", 15_000);
});
});
@@ -503,13 +386,9 @@ test.describe("User-save then diff view (colleague repro)", () => {
// Step 5: the diff must reflect the user's marker.
const diffsContainer = getDiffsContainer(testPage);
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
await expect(diffsContainer.getByText(USER_MARKER, { exact: true })).toBeVisible({
timeout: 15_000,
});
await waitForDiffText(testPage, USER_MARKER, 15_000);
// FIRST_MODIFICATION should still be present (agent's earlier edit).
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
timeout: 5_000,
});
await waitForDiffText(testPage, "FIRST_MODIFICATION", 5_000);
});
});
@@ -528,9 +407,7 @@ test.describe("Untracked file diff update", () => {
// Note: exact match is false because the line shows "line 1: INITIAL_CONTENT"
const diffsContainer = getDiffsContainer(testPage);
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
await expect(diffsContainer.getByText("INITIAL_CONTENT")).toBeVisible({
timeout: 15_000,
});
await waitForDiffText(testPage, "INITIAL_CONTENT", 15_000);
// Click on the session tab to make the chat input visible again
await session.clickSessionChatTab();
@@ -557,16 +434,12 @@ test.describe("Untracked file diff update", () => {
// The diff should now show MODIFIED_CONTENT instead of INITIAL_CONTENT
// Note: exact match is false because the line includes prefix text
// Give extra time for git polling to detect and refresh the diff view
await expect(updatedDiffsContainer.getByText("MODIFIED_CONTENT")).toBeVisible({
timeout: 45_000,
});
await waitForDiffText(testPage, "MODIFIED_CONTENT", 45_000);
// Verify INITIAL_CONTENT is no longer shown
await expect(updatedDiffsContainer.getByText("INITIAL_CONTENT")).toHaveCount(0);
await waitForDiffTextAbsent(testPage, "INITIAL_CONTENT");
// Also verify the new line was added
await expect(updatedDiffsContainer.getByText("NEW_LINE")).toBeVisible({
timeout: 15_000,
});
await waitForDiffText(testPage, "NEW_LINE", 15_000);
});
});
@@ -0,0 +1,109 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
fetchSystemInfo: vi.fn(),
requestRestart: vi.fn(),
}));
vi.mock("@/lib/api/domains/system-api", () => ({
fetchSystemInfo: mocks.fetchSystemInfo,
requestRestart: mocks.requestRestart,
}));
import { useKandevRestart } from "./use-kandev-restart";
beforeEach(() => {
mocks.fetchSystemInfo.mockReset();
mocks.requestRestart.mockReset();
});
describe("useKandevRestart", () => {
it("starts a restart and waits for a new boot id", async () => {
mocks.fetchSystemInfo
.mockResolvedValueOnce({ boot_id: "boot-1" })
.mockResolvedValue({ boot_id: "boot-2" });
mocks.requestRestart.mockResolvedValue({ accepted: true, message: "Restarting" });
const onComplete = vi.fn();
const { result } = renderHook(() => useKandevRestart({ onComplete }));
await act(async () => {
await result.current.start();
});
expect(mocks.requestRestart).toHaveBeenCalledTimes(1);
await waitFor(() => expect(result.current.phase).toBe("done"));
expect(onComplete).toHaveBeenCalledTimes(1);
});
it("keeps waiting while the backend is unreachable", async () => {
mocks.fetchSystemInfo
.mockResolvedValueOnce({ boot_id: "boot-1" })
.mockRejectedValue(new Error("connection refused"));
mocks.requestRestart.mockResolvedValue({ accepted: true, message: "Restarting" });
const { result } = renderHook(() => useKandevRestart());
await act(async () => {
await result.current.start();
});
await waitFor(() => expect(result.current.phase).toBe("restarting"));
expect(result.current.isRestarting).toBe(true);
});
it("reports an error when the restart request fails", async () => {
mocks.fetchSystemInfo.mockResolvedValueOnce({ boot_id: "boot-1" });
mocks.requestRestart.mockRejectedValue(new Error("unsupported launch mode"));
const { result } = renderHook(() => useKandevRestart());
await act(async () => {
await result.current.start();
});
expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(1);
expect(mocks.fetchSystemInfo.mock.invocationCallOrder[0]).toBeLessThan(
mocks.requestRestart.mock.invocationCallOrder[0],
);
expect(result.current.phase).toBe("error");
expect(result.current.errorMessage).toBe("unsupported launch mode");
});
it("ignores duplicate starts while a restart is already active", async () => {
mocks.fetchSystemInfo.mockResolvedValueOnce({ boot_id: "boot-1" });
let releaseRestart!: () => void;
const restartStarted = new Promise<void>((resolve) => {
mocks.requestRestart.mockImplementationOnce(
() =>
new Promise((release) => {
releaseRestart = () => release({ accepted: true, message: "Restarting" });
resolve();
}),
);
});
const { result } = renderHook(() => useKandevRestart());
let firstStart!: Promise<void>;
await act(async () => {
firstStart = result.current.start();
await restartStarted;
});
await act(async () => {
await result.current.start();
});
expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(1);
expect(mocks.requestRestart).toHaveBeenCalledTimes(1);
releaseRestart();
await act(async () => {
await firstStart;
});
await waitFor(() => expect(result.current.phase).toBe("restarting"));
expect(mocks.requestRestart).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,104 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchSystemInfo, requestRestart } from "@/lib/api/domains/system-api";
export type KandevRestartPhase = "idle" | "starting" | "restarting" | "done" | "error";
const POLL_INTERVAL_MS = 2000;
const MAX_DURATION_MS = 3 * 60 * 1000;
type UseKandevRestartArgs = {
onComplete?: () => void;
};
export type KandevRestartController = {
phase: KandevRestartPhase;
errorMessage: string | null;
isRestarting: boolean;
start: () => Promise<void>;
dismiss: () => void;
};
export function useKandevRestart({
onComplete,
}: UseKandevRestartArgs = {}): KandevRestartController {
const [phase, setPhase] = useState<KandevRestartPhase>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [previousBootID, setPreviousBootID] = useState<string | null>(null);
const activeRef = useRef(false);
const startedAtRef = useRef<number | null>(null);
const fail = useCallback((message: string) => {
activeRef.current = false;
setErrorMessage(message);
setPhase("error");
}, []);
const start = useCallback(async () => {
if (activeRef.current) return;
activeRef.current = true;
setErrorMessage(null);
setPhase("starting");
try {
const before = await fetchSystemInfo({ cache: "no-store" });
setPreviousBootID(before.boot_id);
startedAtRef.current = Date.now();
await requestRestart();
setPhase("restarting");
} catch (e) {
fail(e instanceof Error ? e.message : "Failed to restart Kandev");
}
}, [fail]);
const dismiss = useCallback(() => {
activeRef.current = false;
setPhase("idle");
setErrorMessage(null);
setPreviousBootID(null);
startedAtRef.current = null;
}, []);
useEffect(() => {
if (phase !== "restarting") return;
if (!previousBootID) return;
let cancelled = false;
const tick = async () => {
const startedAt = startedAtRef.current ?? Date.now();
if (Date.now() - startedAt > MAX_DURATION_MS) {
if (!cancelled) {
fail("Restart is taking longer than expected. Refresh to check the current status.");
}
return;
}
try {
const info = await fetchSystemInfo({ cache: "no-store" });
if (cancelled) return;
if (info.boot_id && info.boot_id !== previousBootID) {
activeRef.current = false;
setPhase("done");
onComplete?.();
}
} catch {
// Backend unreachable is expected while the supervised process exits
// and starts again. Keep polling until the boot ID changes.
}
};
void tick();
const interval = setInterval(() => void tick(), POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [phase, previousBootID, onComplete, fail]);
return {
phase,
errorMessage,
isRestarting: phase === "starting" || phase === "restarting",
start,
dismiss,
};
}
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@/lib/config", () => ({
getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }),
}));
import { fetchRuntimeFlags, updateRuntimeFlag } from "./runtime-flags-api";
const BASE = "http://api.test/api/v1/runtime-flags";
type FetchInput = Parameters<typeof fetch>[0];
type FetchInit = Parameters<typeof fetch>[1];
const fetchSpy = vi.fn<[FetchInput, FetchInit?], Promise<Response>>();
beforeEach(() => {
fetchSpy.mockReset();
vi.stubGlobal("fetch", fetchSpy);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function lastCall(): { url: string; init: FetchInit | undefined } {
const call = fetchSpy.mock.calls.at(-1);
if (!call) throw new Error("expected fetch to have been called");
return { url: String(call[0]), init: call[1] };
}
describe("runtime flags api", () => {
it("fetchRuntimeFlags forces no-store cache for the runtime flags endpoint", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ flags: [] }));
const res = await fetchRuntimeFlags({ cache: "force-cache" });
const { url, init } = lastCall();
expect(url).toBe(BASE);
expect((init?.method ?? "GET").toUpperCase()).toBe("GET");
expect(init?.cache).toBe("no-store");
expect(res.flags).toEqual([]);
});
it("updateRuntimeFlag PATCHes a boolean override", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ flags: [] }));
await updateRuntimeFlag("features.office", true, {
init: { method: "GET", body: JSON.stringify({ override: false }) },
});
const { url, init } = lastCall();
expect(url).toBe(`${BASE}/features.office`);
expect((init?.method ?? "").toUpperCase()).toBe("PATCH");
expect(init?.body).toBe(JSON.stringify({ override: true }));
});
it("updateRuntimeFlag PATCHes null to clear an override", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ flags: [] }));
await updateRuntimeFlag("debug.devMode", null);
expect(lastCall().init?.body).toBe(JSON.stringify({ override: null }));
});
});
@@ -0,0 +1,26 @@
import { fetchJson, type ApiRequestOptions } from "../client";
import type { RuntimeFlagsResponse } from "@/lib/types/runtime-flags";
const RUNTIME_FLAGS_BASE = "/api/v1/runtime-flags";
export function fetchRuntimeFlags(options?: ApiRequestOptions): Promise<RuntimeFlagsResponse> {
return fetchJson<RuntimeFlagsResponse>(RUNTIME_FLAGS_BASE, {
...options,
cache: "no-store",
});
}
export function updateRuntimeFlag(
key: string,
override: boolean | null,
options?: ApiRequestOptions,
): Promise<RuntimeFlagsResponse> {
return fetchJson<RuntimeFlagsResponse>(`${RUNTIME_FLAGS_BASE}/${encodeURIComponent(key)}`, {
...options,
init: {
...(options?.init ?? {}),
method: "PATCH",
body: JSON.stringify({ override }),
},
});
}
@@ -25,6 +25,8 @@ import {
checkUpdates,
applyUpdate,
fetchSystemJob,
fetchRestartCapability,
requestRestart,
} from "./system-api";
const BASE = "http://api.test/api/v1/system";
@@ -273,3 +275,27 @@ describe("updates", () => {
expect(res.job_id).toBe("self-update-1");
});
});
describe("restart", () => {
it("fetchRestartCapability GETs /restart-capability", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ supported: false, mode: "manual" }));
const res = await fetchRestartCapability();
expect(lastCall().url).toBe(`${BASE}/restart-capability`);
expect(method()).toBe("GET");
expect(res.supported).toBe(false);
});
it("fetchRestartCapability always bypasses cache", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ supported: true, mode: "supervisor" }));
await fetchRestartCapability({ cache: "force-cache" });
expect(lastCall().init?.cache).toBe("no-store");
});
it("requestRestart POSTs /restart", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ accepted: true, message: "Restarting" }));
const res = await requestRestart({ init: { method: "GET" } });
expect(lastCall().url).toBe(`${BASE}/restart`);
expect(method()).toBe("POST");
expect(res.accepted).toBe(true);
});
});
+18
View File
@@ -9,6 +9,8 @@ import type {
LogTailResponse,
UpdatesResponse,
JobAcceptResponse,
RestartCapability,
RestartResponse,
} from "@/lib/types/system";
const SYSTEM_BASE = "/api/v1/system";
@@ -168,3 +170,19 @@ export function applyUpdate(
init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify({ confirm }) },
});
}
// --- Restart ------------------------------------------------------------
export function fetchRestartCapability(options?: ApiRequestOptions): Promise<RestartCapability> {
return fetchJson<RestartCapability>(`${SYSTEM_BASE}/restart-capability`, {
...options,
cache: "no-store",
});
}
export function requestRestart(options?: ApiRequestOptions): Promise<RestartResponse> {
return fetchJson<RestartResponse>(`${SYSTEM_BASE}/restart`, {
...options,
init: { ...(options?.init ?? {}), method: "POST" },
});
}
+1
View File
@@ -9,3 +9,4 @@ export * from "./domains/settings-api";
export * from "./domains/process-api";
export * from "./domains/workflow-api";
export * from "./domains/github-api";
export * from "./domains/runtime-flags-api";
@@ -29,6 +29,8 @@ const INFO: SystemInfo = {
go_version: "go1.24",
os: "darwin",
arch: "arm64",
boot_id: "boot-1",
started_at: "2026-01-01T00:00:00Z",
};
const DISK_USAGE: DiskUsageResponse = {
+27
View File
@@ -0,0 +1,27 @@
export type RuntimeFlagKind = "feature" | "debug";
export type RuntimeFlagSource = "env" | "override" | "profile" | "default";
export type RuntimeFlagStability = "stable" | "beta" | "experimental";
export type RuntimeFlagRiskLevel = "low" | "medium" | "high";
export interface RuntimeFlagState {
key: string;
kind: RuntimeFlagKind;
label: string;
description: string;
stability: RuntimeFlagStability;
risk_level: RuntimeFlagRiskLevel;
risk_description: string;
effective_value: boolean;
default_value: boolean;
override_value: boolean | null;
source: RuntimeFlagSource;
env_var: string;
env_locked: boolean;
restart_required: boolean;
requires_restart_to_apply: boolean;
mutable: boolean;
}
export interface RuntimeFlagsResponse {
flags: RuntimeFlagState[];
}
+15
View File
@@ -9,6 +9,8 @@ export interface SystemInfo {
go_version: string;
os: string;
arch: string;
boot_id: string;
started_at: string;
}
export interface DiskBreakdown {
@@ -155,6 +157,19 @@ export interface JobAcceptResponse {
job_id: string;
}
export interface RestartCapability {
supported: boolean;
mode: "manual" | "supervisor" | string;
adapter?: string;
reason?: string;
details?: Record<string, unknown>;
}
export interface RestartResponse {
accepted: boolean;
message: string;
}
export type LicenseEcosystem = "npm" | "go";
export interface LicenseEntry {
@@ -0,0 +1,72 @@
# 0018: Runtime settings overrides
**Status:** accepted
**Date:** 2026-06-14
**Area:** backend, frontend, cli
## Context
`profiles.yaml` is the shipped source of runtime defaults for production, dev,
and e2e. It works well for releases and launch profiles, but users still need
an in-app way to opt into install-level feature toggles such as Office mode
without editing environment variables. Some toggles affect startup-time service
construction and route registration, so changing them safely requires a clear
restart model.
## Decision
`profiles.yaml` remains immutable shipped defaults. User changes are persisted
as install-level SQLite overrides and resolved at backend startup using this
precedence:
```text
explicit environment variable > persisted install override > profiles.yaml active profile > Go zero value
```
The backend owns a typed runtime-flag registry with user-facing labels,
descriptions, stability metadata, risk descriptions, env-var names, and restart
requirements. The Feature Toggles UI renders that registry state rather than
hardcoding env-var behavior in React.
V1 exposes Office mode and Debug mode. Office mode is marked experimental.
Debug mode is the user-facing toggle for debug behavior, including agent
message debug logs; agent message logs are not a separate top-level toggle.
V1 toggle changes require restart. Restart is launcher/supervisor mediated:
- In CLI-managed modes (`kandev start`, `kandev run`, `kandev dev`), the Go
backend requests restart from the Node launcher, and the launcher restarts
its backend and web children.
- In service-managed modes, restart delegates to systemd or launchd when the
backend can prove it is safe.
- If no supported supervisor is available, the UI shows manual restart
instructions.
The Go backend must not directly fork or exec an unmanaged replacement process,
because it does not own the web child process, launcher logs, browser URL, or
process-tree cleanup.
## Consequences
- Self-host deployment env remains authoritative and auditable.
- Users get a visible, reversible in-app override path.
- Startup-gated features keep predictable initialization semantics.
- The UI can distinguish default, override, env-locked, and pending-restart
states.
- Restart support needs CLI/service coordination rather than a backend-only
handler.
- Future multi-user support must restrict Feature Toggles, especially Debug
mode, to admins.
## Alternatives Considered
1. **Edit `profiles.yaml` from the UI.** Rejected. The file is embedded in
release artifacts and represents shipped defaults, not mutable user state.
2. **Let DB overrides beat environment variables.** Rejected. Deployment env is
the safest authority for managed/self-hosted installs.
3. **Make the backend spawn a replacement process.** Rejected. The Node launcher
or OS service manager owns the process tree.
4. **Expose every profile knob.** Rejected for v1. Mock providers and e2e
tuning are test infrastructure, not user-facing Feature Toggles.
5. **Show agent message logs as a separate toggle.** Rejected. It is a debug
sub-behavior and would duplicate the Debug mode mental model.
+74
View File
@@ -0,0 +1,74 @@
# Restart supervisor owns backend restarts
## Status
accepted
## Context
Runtime settings such as Feature Toggles can change values that are only read
during backend startup. The UI therefore needs a restart path that can apply
saved overrides without asking users to manually find and restart the right
process.
The backend process cannot safely replace itself in every launch environment.
Replaying a saved shell command is fragile: shell quoting, aliases, parent
process state, current directory, and secrets are easy to lose or leak. A
detached child spawned by the backend can also leave duplicate or orphaned
Kandev processes.
The existing Settings -> Updates flow provides a safer precedent. It launches
an out-of-process helper through the service manager, then the helper upgrades,
reinstalls, and restarts the managed service. The frontend treats temporary
backend unavailability as expected and waits for a verifiable post-restart
signal.
## Decision
Kandev restarts are owned by the launcher/supervisor that started the backend.
The backend delegates restart requests to that owner through a narrow local
control protocol.
Normal `kandev` CLI launches should run with a restart-capable supervisor by
default. Direct backend/dev/test execution remains supported, but reports
restart as unsupported.
The restart UI must confirm a new backend process is serving requests before
it reports success. A plain health check is not enough because the old process
can remain healthy briefly after accepting a restart. The backend therefore
exposes a per-process `boot_id` and `started_at`; restart polling succeeds only
after the backend is reachable and `boot_id` has changed.
The supervisor stores structured launch data only. It must not persist or
replay a raw shell command string.
## Consequences
- The backend restart endpoint stays small and adapter-driven.
- The primary local restart path works for normal CLI launches without relying
on systemd, launchd, or container restart policies.
- Service-manager and container restart adapters remain compatibility fallbacks
for environments that do not use the Kandev supervisor.
- Restart support is conservative: if no restart-capable launcher is present,
the API returns unsupported with manual guidance.
- The frontend restart progress flow can reuse the self-update pattern, but it
waits for `boot_id` changes rather than version changes.
## Alternatives Considered
### Store and replay the original run command
Rejected. Shell commands are not a stable or safe launch manifest. They can
lose quoting and aliases, capture secrets, run in the wrong parent context, or
spawn duplicate unmanaged processes.
### Backend spawns a detached replacement process
Rejected. The backend is the process being shut down; it cannot reliably
recreate the parent terminal, service, container, or launch environment that
owns it.
### Only support OS service-manager restart
Rejected as the primary strategy. It is useful for installed services and the
self-update flow, but it does not cover ordinary `kandev` CLI launches.
+2
View File
@@ -23,3 +23,5 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 0015 | [Explicit completion signal for auto-advance](0015-explicit-completion-signal-for-auto-advance.md) | proposed | backend, frontend | 2026-06-04 |
| 0016 | [Read-only absolute file paths](0016-observed-external-file-reads.md) | accepted | backend | 2026-06-14 |
| 0017 | [Resource metrics sampling](0017-resource-metrics-sampling.md) | accepted | backend, frontend, protocol | 2026-06-14 |
| 0018 | [Runtime settings overrides](0018-runtime-settings-overrides.md) | accepted | backend, frontend, cli | 2026-06-14 |
| 0019 | [Restart supervisor owns backend restarts](0019-restart-supervisor.md) | accepted | backend, frontend, cli | 2026-06-14 |
+1
View File
@@ -96,6 +96,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance,
| Spec | Status |
|---|---|
| [system-page](system-page/spec.md) | draft |
| [feature-toggles](feature-toggles/spec.md) | draft |
---
+293
View File
@@ -0,0 +1,293 @@
---
status: draft
created: 2026-06-14
owner: tbd
---
# Feature Toggles
## Why
Users can already opt into runtime features through environment variables and
`profiles.yaml`, but those controls are invisible from the app and require
terminal access. Users need a settings page that explains experimental
features, lets them choose install-level overrides, and clearly handles the
restart required for startup-gated features such as Office mode and Debug mode.
## What
- Kandev adds a **Feature Toggles** settings page at
`/settings/system/feature-toggles`.
- The page shows user-facing runtime toggles, not every profile/env knob.
- V1 exposes two user-facing toggles:
- **Office mode** — enables the autonomous-agent Office surface.
- **Debug mode** — enables local diagnostic/debug behavior, including agent
message debug logs.
- Office mode is marked **Experimental** and includes a concise description of
what it does and the risks of enabling it.
- Debug mode is a single user-facing toggle. Agent message debug logs are part
of Debug mode, not a duplicate top-level toggle.
- Every v1 toggle requires restart before the changed value takes effect.
- The page distinguishes effective value, saved override, default value, and
environment-locked value.
- Explicit environment variables remain authoritative. If an env var controls a
flag, the UI shows the flag as locked and does not allow overriding it.
- Users can reset a saved override back to the profile/env default.
- After saving any restart-required change, the UI shows a persistent restart
banner and offers a restart action when Kandev can safely restart itself.
- If automatic restart is unsupported, the UI shows manual restart guidance.
- `/api/v1/features` continues to return effective feature booleans for SSR
gating and existing `useFeature()` callers.
## Tech stack
- Backend: Go, Gin HTTP handlers, SQLite persistence.
- Frontend: Next.js App Router, React, Zustand, `@kandev/ui` shadcn
components.
- E2E: Playwright under `apps/web/e2e`.
- Runtime defaults: `profiles.yaml` embedded by `apps/backend/internal/profiles`.
## Commands
```bash
make fmt
make typecheck test lint
make -C apps/backend test
cd apps && pnpm --filter @kandev/web typecheck
cd apps && pnpm --filter @kandev/web lint
cd apps/web && pnpm e2e -- settings/feature-toggles.spec.ts settings/mobile-feature-toggles.spec.ts
```
## Project structure
```text
apps/backend/internal/runtimeflags/ Runtime flag registry, store, service, handlers
apps/backend/internal/system/restart/ Restart capability/request support
apps/backend/cmd/kandev/ Startup override application and route wiring
apps/web/app/settings/system/feature-toggles/page.tsx
apps/web/components/settings/system/feature-toggles-settings.tsx
apps/web/lib/api/domains/runtime-flags-api.ts
docs/decisions/0018-runtime-settings-overrides.md
docs/decisions/0019-restart-supervisor.md
```
Planned E2E coverage:
```text
apps/web/e2e/settings/feature-toggles.spec.ts TODO
apps/web/e2e/settings/mobile-feature-toggles.spec.ts TODO
```
## Code style
Use typed contracts at the backend boundary and keep env-var knowledge in the
backend registry rather than duplicating it in React components.
```go
type RuntimeFlagDefinition struct {
Key string
EnvVar string
Kind RuntimeFlagKind
Label string
Description string
Stability RuntimeFlagStability
RiskLevel RuntimeFlagRiskLevel
RiskDescription string
RestartRequired bool
Mutable bool
}
```
Frontend components render the API shape and avoid hardcoding flag-specific
logic except for layout/grouping.
## Data model
Feature Toggle overrides are install-scoped and stored in SQLite.
`runtime_flag_overrides`
| Field | Type | Constraint |
|---|---|---|
| `key` | `TEXT` | primary key, e.g. `features.office` |
| `value` | `INTEGER` | required, `0` or `1` |
| `created_at` | `DATETIME` | required |
| `updated_at` | `DATETIME` | required |
Absence of a row means "use the effective default". A `false` row is a real
override and must not be treated as missing.
## API surface
`GET /api/v1/runtime-flags`
Returns all user-facing runtime toggle states and metadata.
```json
{
"flags": [
{
"key": "features.office",
"kind": "feature",
"label": "Office mode",
"description": "Enables autonomous agent office workflows and related settings.",
"stability": "experimental",
"risk_level": "medium",
"risk_description": "Office mode is still evolving. Workflows, routes, and background automation may change between releases and should be reviewed before relying on them.",
"effective_value": false,
"default_value": false,
"override_value": true,
"source": "override",
"env_var": "KANDEV_FEATURES_OFFICE",
"env_locked": false,
"restart_required": true,
"requires_restart_to_apply": true
}
]
}
```
`PATCH /api/v1/runtime-flags/:key`
```json
{ "override": true }
```
`override` can be `true`, `false`, or `null`. `null` clears the saved override.
Unknown keys return `404`. Env-locked keys reject writes with `409`.
`GET /api/v1/system/restart-capability`
```json
{
"supported": true,
"mode": "cli",
"reason": ""
}
```
`mode` is `cli`, `systemd`, `launchd`, or `unsupported`.
`POST /api/v1/system/restart`
Requests a restart. The response is flushed before shutdown starts so the UI
can enter a waiting state and poll `/health`.
`GET /api/v1/features`
Existing contract remains unchanged: effective feature booleans only.
## State machine
Runtime flag state:
```text
profile default -> override saved -> restart pending -> restarted/effective
\-> override cleared -> restart pending -> restarted/default
```
Restart request state:
```text
idle -> requested -> backend unavailable -> backend healthy -> complete
\-> timeout -> manual recovery shown
```
## Permissions
Kandev is single-user in v1. Every user with settings access can view and change
Feature Toggles. Future multi-user support must restrict Feature Toggles,
especially Debug mode, to admins.
## Failure modes
- **Environment-locked flag** — UI disables the switch and shows the controlling
env var. API rejects override writes with `409`.
- **Override store unavailable** — runtime flags API fails with a recoverable
error; existing `/api/v1/features` continues to use startup-effective config.
- **Restart unsupported** — restart banner shows manual restart instructions
and no dead action button.
- **Restart requested while one is pending** — API rejects or idempotently
reports the pending restart.
- **Backend does not return after restart** — frontend times out and shows
manual recovery guidance.
- **Debug mode enabled** — UI warns that local diagnostic endpoints and agent
message logs may include sensitive prompt, file, and tool content.
## Persistence guarantees
- Saved overrides survive Kandev restarts.
- Effective runtime values are applied at backend startup.
- V1 toggles are startup-gated; changing a value does not affect already
registered routes, constructed services, or running agentctl processes until
restart.
- Environment variables always win over saved overrides.
## Testing strategy
- Backend unit tests cover registry metadata, override persistence, precedence,
env locking, and pending-restart computation.
- Backend handler tests cover runtime flag APIs and restart capability/request
behavior.
- Frontend component tests cover Office experimental copy, Debug mode copy,
env-locked rows, save/reset actions, and restart banner states.
- Desktop and mobile Playwright tests cover the user workflow from settings
navigation through saving a restart-required toggle.
## Boundaries
- Always: keep `profiles.yaml` as immutable shipped defaults.
- Always: keep env vars authoritative over DB overrides.
- Always: show restart-required state for v1 toggles.
- Always: include mobile coverage for the settings page.
- Ask first: adding a new external flag service or changing the auth model.
- Ask first: exposing mocks/e2e tuning as user-facing toggles.
- Never: make the Go backend fork an unmanaged replacement process for restart.
- Never: expose agent message debug logs as a separate top-level toggle in v1.
## Scenarios
- **GIVEN** Office mode is using the production default, **WHEN** the user opens
`/settings/system/feature-toggles`, **THEN** Office mode appears off with a
`Default` source, an `Experimental` badge, and risk text.
- **GIVEN** Office mode is off, **WHEN** the user enables it, **THEN** the
override is saved and the page shows that restart is required before Office
mode takes effect.
- **GIVEN** `KANDEV_FEATURES_OFFICE=true` is set in the environment, **WHEN** the
user opens Feature Toggles, **THEN** Office mode appears on, env-locked, and
cannot be changed from the UI.
- **GIVEN** Debug mode is off, **WHEN** the user enables it, **THEN** the page
explains that local diagnostic endpoints and agent message debug logs will be
enabled after restart.
- **GIVEN** a toggle override exists, **WHEN** the user clicks `Reset to default`,
**THEN** the override is removed and the page shows restart-required state if
the effective runtime value differs from the restored default.
- **GIVEN** restart capability is supported, **WHEN** the user clicks
`Restart Kandev`, **THEN** the UI enters `Restarting...`, polls `/health`, and
refreshes the page after the backend returns healthy.
- **GIVEN** restart capability is unsupported, **WHEN** a restart-required change
is saved, **THEN** the UI shows manual restart instructions instead of a
clickable restart button.
- **GIVEN** a mobile viewport, **WHEN** the user opens Feature Toggles, **THEN**
all descriptions, badges, switches, reset buttons, and restart actions are
reachable without horizontal scrolling.
## Success criteria
- A user can enable or disable Office mode from Feature Toggles and understand
that restart is required.
- Office mode clearly communicates experimental status and risk.
- A user can enable or disable Debug mode without seeing duplicate agent-message
debug toggles.
- Env-controlled flags are accurately shown as locked.
- Restart-required changes have a clear supported or manual restart path.
- Existing SSR feature gating keeps working through `/api/v1/features`.
- Desktop and mobile tests cover the core workflow.
## Out of scope
- Live-applying Office mode or Debug mode without restart.
- Per-user, workspace-scoped, or percentage rollout flags.
- Exposing mock providers, e2e tuning, or arbitrary env vars as user toggles.
- A third-party feature flag service.
- Multi-user admin enforcement in v1.