From fd421848fb9b73ae11438a384631a70140e9b0b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 15 Jun 2026 13:33:45 +0100 Subject: [PATCH] feat: add feature toggles settings (#1372) --- AGENTS.md | 4 +- apps/backend/cmd/kandev/e2e_reset.go | 3 + apps/backend/cmd/kandev/helpers.go | 5 + apps/backend/cmd/kandev/main.go | 25 ++ apps/backend/cmd/kandev/storage.go | 7 + apps/backend/cmd/kandev/types.go | 5 +- apps/backend/cmd/preview/build.go | 21 ++ apps/backend/cmd/preview/deploy.go | 5 + apps/backend/cmd/preview/sprite_ops.go | 28 +- apps/backend/cmd/preview/sprite_ops_test.go | 19 +- .../office/costs/modelsdev/client_test.go | 14 +- apps/backend/internal/profiles/profiles.go | 36 +++ .../internal/profiles/profiles_test.go | 31 ++ apps/backend/internal/runtimeflags/config.go | 84 +++++ .../internal/runtimeflags/config_test.go | 100 ++++++ .../backend/internal/runtimeflags/handlers.go | 58 ++++ .../internal/runtimeflags/handlers_test.go | 100 ++++++ .../backend/internal/runtimeflags/registry.go | 49 +++ .../internal/runtimeflags/registry_test.go | 38 +++ apps/backend/internal/runtimeflags/service.go | 136 ++++++++ .../internal/runtimeflags/service_test.go | 124 ++++++++ apps/backend/internal/runtimeflags/sqlite.go | 80 +++++ .../internal/runtimeflags/store_test.go | 63 ++++ apps/backend/internal/runtimeflags/types.go | 74 +++++ apps/backend/internal/system/info/info.go | 26 +- .../backend/internal/system/info/info_test.go | 23 ++ .../internal/system/restart/handler.go | 38 +++ .../internal/system/restart/handler_test.go | 116 +++++++ .../internal/system/restart/manager.go | 36 +++ .../internal/system/restart/supervisor.go | 132 ++++++++ .../system/restart/supervisor_test.go | 113 +++++++ .../internal/system/restart/unsupported.go | 34 ++ apps/backend/internal/system/system.go | 5 + apps/cli/src/cli.ts | 1 + apps/cli/src/dev.ts | 23 +- apps/cli/src/run.ts | 27 +- apps/cli/src/shared.ts | 4 + apps/cli/src/start.ts | 37 ++- apps/cli/src/supervisor/backend.test.ts | 13 + apps/cli/src/supervisor/backend.ts | 117 +++++++ apps/cli/src/supervisor/child.ts | 74 +++++ apps/cli/src/supervisor/control.test.ts | 77 +++++ apps/cli/src/supervisor/control.ts | 111 +++++++ apps/cli/src/supervisor/manifest.test.ts | 85 +++++ apps/cli/src/supervisor/manifest.ts | 91 ++++++ apps/cli/src/supervisor/paths.ts | 29 ++ apps/web/app/actions/features.test.ts | 60 ++++ apps/web/app/actions/features.ts | 15 + apps/web/app/layout.tsx | 10 +- .../settings/system/feature-toggles/page.tsx | 23 ++ .../sections/settings/settings-tree.test.ts | 1 + .../sections/settings/system-group.tsx | 2 + .../system/feature-toggle-card.test.tsx | 148 +++++++++ .../settings/system/feature-toggle-card.tsx | 96 ++++++ .../system/feature-toggles-settings.test.tsx | 67 ++++ .../system/feature-toggles-settings.tsx | 215 +++++++++++++ .../system/restart-progress-dialog.tsx | 94 ++++++ apps/web/e2e/tests/git/diff-update-helpers.ts | 206 ++++++++++++ apps/web/e2e/tests/git/diff-update.spec.ts | 259 ++++------------ .../domains/system/use-kandev-restart.test.ts | 109 +++++++ .../domains/system/use-kandev-restart.ts | 104 +++++++ .../lib/api/domains/runtime-flags-api.test.ts | 65 ++++ apps/web/lib/api/domains/runtime-flags-api.ts | 26 ++ apps/web/lib/api/domains/system-api.test.ts | 26 ++ apps/web/lib/api/domains/system-api.ts | 18 ++ apps/web/lib/api/index.ts | 1 + .../state/slices/system/system-slice.test.ts | 2 + apps/web/lib/types/runtime-flags.ts | 27 ++ apps/web/lib/types/system.ts | 15 + .../0018-runtime-settings-overrides.md | 72 +++++ docs/decisions/0019-restart-supervisor.md | 74 +++++ docs/decisions/INDEX.md | 2 + docs/specs/INDEX.md | 1 + docs/specs/feature-toggles/spec.md | 293 ++++++++++++++++++ 74 files changed, 4096 insertions(+), 256 deletions(-) create mode 100644 apps/backend/internal/runtimeflags/config.go create mode 100644 apps/backend/internal/runtimeflags/config_test.go create mode 100644 apps/backend/internal/runtimeflags/handlers.go create mode 100644 apps/backend/internal/runtimeflags/handlers_test.go create mode 100644 apps/backend/internal/runtimeflags/registry.go create mode 100644 apps/backend/internal/runtimeflags/registry_test.go create mode 100644 apps/backend/internal/runtimeflags/service.go create mode 100644 apps/backend/internal/runtimeflags/service_test.go create mode 100644 apps/backend/internal/runtimeflags/sqlite.go create mode 100644 apps/backend/internal/runtimeflags/store_test.go create mode 100644 apps/backend/internal/runtimeflags/types.go create mode 100644 apps/backend/internal/system/restart/handler.go create mode 100644 apps/backend/internal/system/restart/handler_test.go create mode 100644 apps/backend/internal/system/restart/manager.go create mode 100644 apps/backend/internal/system/restart/supervisor.go create mode 100644 apps/backend/internal/system/restart/supervisor_test.go create mode 100644 apps/backend/internal/system/restart/unsupported.go create mode 100644 apps/cli/src/supervisor/backend.test.ts create mode 100644 apps/cli/src/supervisor/backend.ts create mode 100644 apps/cli/src/supervisor/child.ts create mode 100644 apps/cli/src/supervisor/control.test.ts create mode 100644 apps/cli/src/supervisor/control.ts create mode 100644 apps/cli/src/supervisor/manifest.test.ts create mode 100644 apps/cli/src/supervisor/manifest.ts create mode 100644 apps/cli/src/supervisor/paths.ts create mode 100644 apps/web/app/actions/features.test.ts create mode 100644 apps/web/app/settings/system/feature-toggles/page.tsx create mode 100644 apps/web/components/settings/system/feature-toggle-card.test.tsx create mode 100644 apps/web/components/settings/system/feature-toggle-card.tsx create mode 100644 apps/web/components/settings/system/feature-toggles-settings.test.tsx create mode 100644 apps/web/components/settings/system/feature-toggles-settings.tsx create mode 100644 apps/web/components/settings/system/restart-progress-dialog.tsx create mode 100644 apps/web/e2e/tests/git/diff-update-helpers.ts create mode 100644 apps/web/hooks/domains/system/use-kandev-restart.test.ts create mode 100644 apps/web/hooks/domains/system/use-kandev-restart.ts create mode 100644 apps/web/lib/api/domains/runtime-flags-api.test.ts create mode 100644 apps/web/lib/api/domains/runtime-flags-api.ts create mode 100644 apps/web/lib/types/runtime-flags.ts create mode 100644 docs/decisions/0018-runtime-settings-overrides.md create mode 100644 docs/decisions/0019-restart-supervisor.md create mode 100644 docs/specs/feature-toggles/spec.md diff --git a/AGENTS.md b/AGENTS.md index 175e63b685..7ad43a152b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. --- diff --git a/apps/backend/cmd/kandev/e2e_reset.go b/apps/backend/cmd/kandev/e2e_reset.go index d49645bf77..e3d09495dc 100644 --- a/apps/backend/cmd/kandev/e2e_reset.go +++ b/apps/backend/cmd/kandev/e2e_reset.go @@ -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 diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 358f0f0316..1b2fe313cb 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -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) diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 5d3238318d..04832f9359 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -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, diff --git a/apps/backend/cmd/kandev/storage.go b/apps/backend/cmd/kandev/storage.go index 33c062442c..cad4098e92 100644 --- a/apps/backend/cmd/kandev/storage.go +++ b/apps/backend/cmd/kandev/storage.go @@ -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 } diff --git a/apps/backend/cmd/kandev/types.go b/apps/backend/cmd/kandev/types.go index 6140ed42a8..b7c743fa92 100644 --- a/apps/backend/cmd/kandev/types.go +++ b/apps/backend/cmd/kandev/types.go @@ -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. diff --git a/apps/backend/cmd/preview/build.go b/apps/backend/cmd/preview/build.go index 6190e7fa06..93cf7aea6a 100644 --- a/apps/backend/cmd/preview/build.go +++ b/apps/backend/cmd/preview/build.go @@ -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) diff --git a/apps/backend/cmd/preview/deploy.go b/apps/backend/cmd/preview/deploy.go index 90e86bbdf4..05f33190fc 100644 --- a/apps/backend/cmd/preview/deploy.go +++ b/apps/backend/cmd/preview/deploy.go @@ -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) diff --git a/apps/backend/cmd/preview/sprite_ops.go b/apps/backend/cmd/preview/sprite_ops.go index a87925a788..73df392a11 100644 --- a/apps/backend/cmd/preview/sprite_ops.go +++ b/apps/backend/cmd/preview/sprite_ops.go @@ -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 diff --git a/apps/backend/cmd/preview/sprite_ops_test.go b/apps/backend/cmd/preview/sprite_ops_test.go index 3b73339ab6..70232b534b 100644 --- a/apps/backend/cmd/preview/sprite_ops_test.go +++ b/apps/backend/cmd/preview/sprite_ops_test.go @@ -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") + } } diff --git a/apps/backend/internal/office/costs/modelsdev/client_test.go b/apps/backend/internal/office/costs/modelsdev/client_test.go index df4e06a3b9..2e4610693c 100644 --- a/apps/backend/internal/office/costs/modelsdev/client_test.go +++ b/apps/backend/internal/office/costs/modelsdev/client_test.go @@ -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") } } diff --git a/apps/backend/internal/profiles/profiles.go b/apps/backend/internal/profiles/profiles.go index 5d137f24e3..fce283c184 100644 --- a/apps/backend/internal/profiles/profiles.go +++ b/apps/backend/internal/profiles/profiles.go @@ -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. diff --git a/apps/backend/internal/profiles/profiles_test.go b/apps/backend/internal/profiles/profiles_test.go index 5b77780f0a..8033dd4d89 100644 --- a/apps/backend/internal/profiles/profiles_test.go +++ b/apps/backend/internal/profiles/profiles_test.go @@ -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() + }) +} diff --git a/apps/backend/internal/runtimeflags/config.go b/apps/backend/internal/runtimeflags/config.go new file mode 100644 index 0000000000..5e76025820 --- /dev/null +++ b/apps/backend/internal/runtimeflags/config.go @@ -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 + } +} diff --git a/apps/backend/internal/runtimeflags/config_test.go b/apps/backend/internal/runtimeflags/config_test.go new file mode 100644 index 0000000000..de64d2ffa0 --- /dev/null +++ b/apps/backend/internal/runtimeflags/config_test.go @@ -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) + }) +} diff --git a/apps/backend/internal/runtimeflags/handlers.go b/apps/backend/internal/runtimeflags/handlers.go new file mode 100644 index 0000000000..ef991b476a --- /dev/null +++ b/apps/backend/internal/runtimeflags/handlers.go @@ -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}) +} diff --git a/apps/backend/internal/runtimeflags/handlers_test.go b/apps/backend/internal/runtimeflags/handlers_test.go new file mode 100644 index 0000000000..02228c7be2 --- /dev/null +++ b/apps/backend/internal/runtimeflags/handlers_test.go @@ -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) + } +} diff --git a/apps/backend/internal/runtimeflags/registry.go b/apps/backend/internal/runtimeflags/registry.go new file mode 100644 index 0000000000..116e597a1f --- /dev/null +++ b/apps/backend/internal/runtimeflags/registry.go @@ -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 +} diff --git a/apps/backend/internal/runtimeflags/registry_test.go b/apps/backend/internal/runtimeflags/registry_test.go new file mode 100644 index 0000000000..c2843d3f6c --- /dev/null +++ b/apps/backend/internal/runtimeflags/registry_test.go @@ -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") + } +} diff --git a/apps/backend/internal/runtimeflags/service.go b/apps/backend/internal/runtimeflags/service.go new file mode 100644 index 0000000000..f39fb8d004 --- /dev/null +++ b/apps/backend/internal/runtimeflags/service.go @@ -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 +} diff --git a/apps/backend/internal/runtimeflags/service_test.go b/apps/backend/internal/runtimeflags/service_test.go new file mode 100644 index 0000000000..61abe45090 --- /dev/null +++ b/apps/backend/internal/runtimeflags/service_test.go @@ -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{} +} diff --git a/apps/backend/internal/runtimeflags/sqlite.go b/apps/backend/internal/runtimeflags/sqlite.go new file mode 100644 index 0000000000..b9467b7456 --- /dev/null +++ b/apps/backend/internal/runtimeflags/sqlite.go @@ -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 +} diff --git a/apps/backend/internal/runtimeflags/store_test.go b/apps/backend/internal/runtimeflags/store_test.go new file mode 100644 index 0000000000..f8aab30e59 --- /dev/null +++ b/apps/backend/internal/runtimeflags/store_test.go @@ -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 +} diff --git a/apps/backend/internal/runtimeflags/types.go b/apps/backend/internal/runtimeflags/types.go new file mode 100644 index 0000000000..e53570c2a9 --- /dev/null +++ b/apps/backend/internal/runtimeflags/types.go @@ -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 +} diff --git a/apps/backend/internal/system/info/info.go b/apps/backend/internal/system/info/info.go index 1dc4ed6e11..beb5ff4959 100644 --- a/apps/backend/internal/system/info/info.go +++ b/apps/backend/internal/system/info/info.go @@ -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[:]) +} diff --git a/apps/backend/internal/system/info/info_test.go b/apps/backend/internal/system/info/info_test.go index c9c6d6b6db..daf9d7e3a9 100644 --- a/apps/backend/internal/system/info/info_test.go +++ b/apps/backend/internal/system/info/info_test.go @@ -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) + } } diff --git a/apps/backend/internal/system/restart/handler.go b/apps/backend/internal/system/restart/handler.go new file mode 100644 index 0000000000..f569588078 --- /dev/null +++ b/apps/backend/internal/system/restart/handler.go @@ -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) + } +} diff --git a/apps/backend/internal/system/restart/handler_test.go b/apps/backend/internal/system/restart/handler_test.go new file mode 100644 index 0000000000..c904bb8689 --- /dev/null +++ b/apps/backend/internal/system/restart/handler_test.go @@ -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 +} diff --git a/apps/backend/internal/system/restart/manager.go b/apps/backend/internal/system/restart/manager.go new file mode 100644 index 0000000000..c847382c0a --- /dev/null +++ b/apps/backend/internal/system/restart/manager.go @@ -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") diff --git a/apps/backend/internal/system/restart/supervisor.go b/apps/backend/internal/system/restart/supervisor.go new file mode 100644 index 0000000000..84770bf4f5 --- /dev/null +++ b/apps/backend/internal/system/restart/supervisor.go @@ -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, + } +} diff --git a/apps/backend/internal/system/restart/supervisor_test.go b/apps/backend/internal/system/restart/supervisor_test.go new file mode 100644 index 0000000000..5138fd67f4 --- /dev/null +++ b/apps/backend/internal/system/restart/supervisor_test.go @@ -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", + }) +} diff --git a/apps/backend/internal/system/restart/unsupported.go b/apps/backend/internal/system/restart/unsupported.go new file mode 100644 index 0000000000..52ac5f92d3 --- /dev/null +++ b/apps/backend/internal/system/restart/unsupported.go @@ -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 +} diff --git a/apps/backend/internal/system/system.go b/apps/backend/internal/system/system.go index 88a160d62b..3db0e78910 100644 --- a/apps/backend/internal/system/system.go +++ b/apps/backend/internal/system/system.go @@ -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)) diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index cbec367442..78afdc63f5 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -138,6 +138,7 @@ async function main(): Promise { webPort, verbose: options.verbose, debug: options.debug, + headless: options.headless, }); return; } diff --git a/apps/cli/src/dev.ts b/apps/cli/src/dev.ts index aa385751fb..a9438f7015 100644 --- a/apps/cli/src/dev.ts +++ b/apps/cli/src/dev.ts @@ -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..."); diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index d49bd1c88f..069e46d1ac 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -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; backendProc: ReturnType; 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); diff --git a/apps/cli/src/shared.ts b/apps/cli/src/shared.ts index d9fd1215d9..2f24ec42c9 100644 --- a/apps/cli/src/shared.ts +++ b/apps/cli/src/shared.ts @@ -244,9 +244,13 @@ export function networkUrlsForPort(port: number, hosts: string[]): string[] { export function attachBackendExitHandler( backendProc: ChildProcess, supervisor: ReturnType, + 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)); }); diff --git a/apps/cli/src/start.ts b/apps/cli/src/start.ts index 4d65b54ce7..41321aeb89 100644 --- a/apps/cli/src/start.ts +++ b/apps/cli/src/start.ts @@ -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 { 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); } diff --git a/apps/cli/src/supervisor/backend.test.ts b/apps/cli/src/supervisor/backend.test.ts new file mode 100644 index 0000000000..12b68fb72a --- /dev/null +++ b/apps/cli/src/supervisor/backend.test.ts @@ -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); + }); +}); diff --git a/apps/cli/src/supervisor/backend.ts b/apps/cli/src/supervisor/backend.ts new file mode 100644 index 0000000000..ce238d6c20 --- /dev/null +++ b/apps/cli/src/supervisor/backend.ts @@ -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; +}): Promise { + 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; +} diff --git a/apps/cli/src/supervisor/child.ts b/apps/cli/src/supervisor/child.ts new file mode 100644 index 0000000000..c37caefca2 --- /dev/null +++ b/apps/cli/src/supervisor/child.ts @@ -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; + stop: () => Promise; +}; + +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 { + 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(); + }); + }); +} diff --git a/apps/cli/src/supervisor/control.test.ts b/apps/cli/src/supervisor/control.test.ts new file mode 100644 index 0000000000..057015919a --- /dev/null +++ b/apps/cli/src/supervisor/control.test.ts @@ -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((resolve) => { + markRestartStarted = resolve; + }); + const restartReleased = new Promise((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 { + 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"); +} diff --git a/apps/cli/src/supervisor/control.ts b/apps/cli/src/supervisor/control.ts new file mode 100644 index 0000000000..f74c1bd572 --- /dev/null +++ b/apps/cli/src/supervisor/control.ts @@ -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; +}; + +export function startControlServer( + socket: string, + onRestart: () => Promise, +): Promise { + 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((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 { + 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 { + 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" }; +} diff --git a/apps/cli/src/supervisor/manifest.test.ts b/apps/cli/src/supervisor/manifest.test.ts new file mode 100644 index 0000000000..4b49f146f6 --- /dev/null +++ b/apps/cli/src/supervisor/manifest.test.ts @@ -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; +} diff --git a/apps/cli/src/supervisor/manifest.ts b/apps/cli/src/supervisor/manifest.ts new file mode 100644 index 0000000000..0724bf1ba9 --- /dev/null +++ b/apps/cli/src/supervisor/manifest.ts @@ -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; + home_dir: string; + port: number; + mode: string; + created_at: string; +}; + +export type LaunchManifestInput = Omit & { + 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 { + const out: Record = {}; + for (const key of ENV_ALLOWLIST) { + const value = env[key]; + if (value !== undefined) { + out[key] = value; + } + } + return out; +} diff --git a/apps/cli/src/supervisor/paths.ts b/apps/cli/src/supervisor/paths.ts new file mode 100644 index 0000000000..3c92f04b8a --- /dev/null +++ b/apps/cli/src/supervisor/paths.ts @@ -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; +} diff --git a/apps/web/app/actions/features.test.ts b/apps/web/app/actions/features.test.ts new file mode 100644 index 0000000000..e4818b0385 --- /dev/null +++ b/apps/web/app/actions/features.test.ts @@ -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[0]; +type FetchInit = Parameters[1]; + +const fetchSpy = vi.fn<[FetchInput, FetchInit?], Promise>(); + +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); + }); +}); diff --git a/apps/web/app/actions/features.ts b/apps/web/app/actions/features.ts index f8578ec004..1f39e8381c 100644 --- a/apps/web/app/actions/features.ts +++ b/apps/web/app/actions/features.ts @@ -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 { } } +export async function getRuntimeDebugModeAction(): Promise { + 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, defaults: FeatureFlags): FeatureFlags { const out = { ...defaults }; for (const key of Object.keys(out) as (keyof FeatureFlags)[]) { diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index d2de6c1cb7..bf000289eb 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -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 diff --git a/apps/web/app/settings/system/feature-toggles/page.tsx b/apps/web/app/settings/system/feature-toggles/page.tsx new file mode 100644 index 0000000000..6377e4254f --- /dev/null +++ b/apps/web/app/settings/system/feature-toggles/page.tsx @@ -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 ( + + + + ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree.test.ts b/apps/web/components/app-sidebar/sections/settings/settings-tree.test.ts index 3571953c8d..17cb987532 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-tree.test.ts +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree.test.ts @@ -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"); }); diff --git a/apps/web/components/app-sidebar/sections/settings/system-group.tsx b/apps/web/components/app-sidebar/sections/settings/system-group.tsx index 153d35b5bc..b79d10fbcd 100644 --- a/apps/web/components/app-sidebar/sections/settings/system-group.tsx +++ b/apps/web/components/app-sidebar/sections/settings/system-group.tsx @@ -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 }, diff --git a/apps/web/components/settings/system/feature-toggle-card.test.tsx b/apps/web/components/settings/system/feature-toggle-card.test.tsx new file mode 100644 index 0000000000..83406f7a91 --- /dev/null +++ b/apps/web/components/settings/system/feature-toggle-card.test.tsx @@ -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; + }) => ( + + {flag.env_locked && ( + + + Controlled by launch environment + + )} + + + + ); +} + +function FlagBadges({ flag }: { flag: RuntimeFlagState }) { + return ( + <> + {flag.stability === "experimental" && ( + + + Experimental + + )} + {flag.kind === "debug" && Debug} + + ); +} + +function FlagMetadata({ flag }: { flag: RuntimeFlagState }) { + return ( +
+ Source: {sourceLabel(flag)} + Env: {flag.env_var} + {flag.restart_required && Requires restart} + {flag.requires_restart_to_apply && ( + Pending restart + )} +
+ ); +} + +function sourceLabel(flag: RuntimeFlagState): string { + if (flag.source === "env") return "Environment"; + if (flag.source === "override") return "Saved override"; + return "Default"; +} diff --git a/apps/web/components/settings/system/feature-toggles-settings.test.tsx b/apps/web/components/settings/system/feature-toggles-settings.test.tsx new file mode 100644 index 0000000000..aeb616b62b --- /dev/null +++ b/apps/web/components/settings/system/feature-toggles-settings.test.tsx @@ -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; + }) => + + + )} + + + ); +} + +function RestartRequiredAlert({ + capability, + restarting, + onRestart, +}: { + capability: RestartCapability | null; + restarting: boolean; + onRestart: () => void; +}) { + const supported = capability?.supported === true; + return ( + + + + Restart required + + + + + Saved toggle changes will apply the next time Kandev starts. + {!supported && " Restart it from your terminal or service manager when convenient."} + + {supported && ( + + )} + + + ); +} + +function RestartSupportInfo({ + supported, + reason, +}: { + supported: boolean; + reason: string | undefined; +}) { + return ( + + + + + + {restartSupportMessage(supported, reason)} + + + ); +} + +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 { + seqRef.current += 1; + return seqRef.current; +} + +function setFlagsIfLatest( + seqRef: MutableRefObject, + seq: number, + flags: RuntimeFlagState[], + setFlags: Dispatch>, +) { + if (seq === seqRef.current) { + setFlags(flags); + } +} diff --git a/apps/web/components/settings/system/restart-progress-dialog.tsx b/apps/web/components/settings/system/restart-progress-dialog.tsx new file mode 100644 index 0000000000..41433612d7 --- /dev/null +++ b/apps/web/components/settings/system/restart-progress-dialog.tsx @@ -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 ( + !open && (done || failed) && onDismiss()}> + + + + + {restartTitle(phase)} + + {restartDescription(phase, errorMessage)} + + {(done || failed) && ( + + + + )} + + + ); +} + +function RestartStatusIcon({ phase }: { phase: KandevRestartPhase }) { + if (phase === "done") return ; + if (phase === "error") return ; + return ; +} + +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 ""; + } +} diff --git a/apps/web/e2e/tests/git/diff-update-helpers.ts b/apps/web/e2e/tests/git/diff-update-helpers.ts new file mode 100644 index 0000000000..2c71279d13 --- /dev/null +++ b/apps/web/e2e/tests/git/diff-update-helpers.ts @@ -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; + gitStatus: { + byEnvironmentId: Record< + string, + { files?: Record } | 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); +} diff --git a/apps/web/e2e/tests/git/diff-update.spec.ts b/apps/web/e2e/tests/git/diff-update.spec.ts index f43014747c..24c8284b90 100644 --- a/apps/web/e2e/tests/git/diff-update.spec.ts +++ b/apps/web/e2e/tests/git/diff-update.spec.ts @@ -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); }); }); diff --git a/apps/web/hooks/domains/system/use-kandev-restart.test.ts b/apps/web/hooks/domains/system/use-kandev-restart.test.ts new file mode 100644 index 0000000000..2b72a0d75c --- /dev/null +++ b/apps/web/hooks/domains/system/use-kandev-restart.test.ts @@ -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((resolve) => { + mocks.requestRestart.mockImplementationOnce( + () => + new Promise((release) => { + releaseRestart = () => release({ accepted: true, message: "Restarting" }); + resolve(); + }), + ); + }); + + const { result } = renderHook(() => useKandevRestart()); + + let firstStart!: Promise; + 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); + }); +}); diff --git a/apps/web/hooks/domains/system/use-kandev-restart.ts b/apps/web/hooks/domains/system/use-kandev-restart.ts new file mode 100644 index 0000000000..7312de1d6b --- /dev/null +++ b/apps/web/hooks/domains/system/use-kandev-restart.ts @@ -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; + dismiss: () => void; +}; + +export function useKandevRestart({ + onComplete, +}: UseKandevRestartArgs = {}): KandevRestartController { + const [phase, setPhase] = useState("idle"); + const [errorMessage, setErrorMessage] = useState(null); + const [previousBootID, setPreviousBootID] = useState(null); + const activeRef = useRef(false); + const startedAtRef = useRef(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, + }; +} diff --git a/apps/web/lib/api/domains/runtime-flags-api.test.ts b/apps/web/lib/api/domains/runtime-flags-api.test.ts new file mode 100644 index 0000000000..501596d600 --- /dev/null +++ b/apps/web/lib/api/domains/runtime-flags-api.test.ts @@ -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[0]; +type FetchInit = Parameters[1]; + +const fetchSpy = vi.fn<[FetchInput, FetchInit?], Promise>(); + +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 })); + }); +}); diff --git a/apps/web/lib/api/domains/runtime-flags-api.ts b/apps/web/lib/api/domains/runtime-flags-api.ts new file mode 100644 index 0000000000..b84bef8b14 --- /dev/null +++ b/apps/web/lib/api/domains/runtime-flags-api.ts @@ -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 { + return fetchJson(RUNTIME_FLAGS_BASE, { + ...options, + cache: "no-store", + }); +} + +export function updateRuntimeFlag( + key: string, + override: boolean | null, + options?: ApiRequestOptions, +): Promise { + return fetchJson(`${RUNTIME_FLAGS_BASE}/${encodeURIComponent(key)}`, { + ...options, + init: { + ...(options?.init ?? {}), + method: "PATCH", + body: JSON.stringify({ override }), + }, + }); +} diff --git a/apps/web/lib/api/domains/system-api.test.ts b/apps/web/lib/api/domains/system-api.test.ts index 21efd36947..64f3c98640 100644 --- a/apps/web/lib/api/domains/system-api.test.ts +++ b/apps/web/lib/api/domains/system-api.test.ts @@ -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); + }); +}); diff --git a/apps/web/lib/api/domains/system-api.ts b/apps/web/lib/api/domains/system-api.ts index c06399f874..f7a75b4d3b 100644 --- a/apps/web/lib/api/domains/system-api.ts +++ b/apps/web/lib/api/domains/system-api.ts @@ -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 { + return fetchJson(`${SYSTEM_BASE}/restart-capability`, { + ...options, + cache: "no-store", + }); +} + +export function requestRestart(options?: ApiRequestOptions): Promise { + return fetchJson(`${SYSTEM_BASE}/restart`, { + ...options, + init: { ...(options?.init ?? {}), method: "POST" }, + }); +} diff --git a/apps/web/lib/api/index.ts b/apps/web/lib/api/index.ts index 63f4c8e829..13f5048200 100644 --- a/apps/web/lib/api/index.ts +++ b/apps/web/lib/api/index.ts @@ -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"; diff --git a/apps/web/lib/state/slices/system/system-slice.test.ts b/apps/web/lib/state/slices/system/system-slice.test.ts index 7d354be955..f7ccf94ea8 100644 --- a/apps/web/lib/state/slices/system/system-slice.test.ts +++ b/apps/web/lib/state/slices/system/system-slice.test.ts @@ -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 = { diff --git a/apps/web/lib/types/runtime-flags.ts b/apps/web/lib/types/runtime-flags.ts new file mode 100644 index 0000000000..c9064433d3 --- /dev/null +++ b/apps/web/lib/types/runtime-flags.ts @@ -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[]; +} diff --git a/apps/web/lib/types/system.ts b/apps/web/lib/types/system.ts index fcb4a7440d..356fa8dba0 100644 --- a/apps/web/lib/types/system.ts +++ b/apps/web/lib/types/system.ts @@ -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; +} + +export interface RestartResponse { + accepted: boolean; + message: string; +} + export type LicenseEcosystem = "npm" | "go"; export interface LicenseEntry { diff --git a/docs/decisions/0018-runtime-settings-overrides.md b/docs/decisions/0018-runtime-settings-overrides.md new file mode 100644 index 0000000000..ac6f9fc2da --- /dev/null +++ b/docs/decisions/0018-runtime-settings-overrides.md @@ -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. diff --git a/docs/decisions/0019-restart-supervisor.md b/docs/decisions/0019-restart-supervisor.md new file mode 100644 index 0000000000..30080e0fdd --- /dev/null +++ b/docs/decisions/0019-restart-supervisor.md @@ -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. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 0595868c97..b2875c2a6d 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -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 | diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 4bdf497beb..ce46abfd0f 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -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 | --- diff --git a/docs/specs/feature-toggles/spec.md b/docs/specs/feature-toggles/spec.md new file mode 100644 index 0000000000..88a8244dc9 --- /dev/null +++ b/docs/specs/feature-toggles/spec.md @@ -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.