feat: add feature toggles settings (#1372)
This commit is contained in:
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
func TestBuildExtractScript(t *testing.T) {
|
||||
script := buildExtractScript(12345)
|
||||
|
||||
if !strings.Contains(script, "KANDEV_SERVER_PORT=12345") {
|
||||
t.Errorf("expected KANDEV_SERVER_PORT=12345 in script, got:\n%s", script)
|
||||
if !strings.Contains(script, "--backend-port 12345") {
|
||||
t.Errorf("expected --backend-port 12345 in script, got:\n%s", script)
|
||||
}
|
||||
if !strings.Contains(script, "rm -rf /data") {
|
||||
t.Errorf("expected rm -rf /data in script")
|
||||
@@ -17,4 +17,19 @@ func TestBuildExtractScript(t *testing.T) {
|
||||
if !strings.Contains(script, "KANDEV_MOCK_AGENT=only") {
|
||||
t.Errorf("expected KANDEV_MOCK_AGENT=only in script")
|
||||
}
|
||||
if !strings.Contains(script, "ln -sf /usr/local/lib/kandev-cli/bin/cli.js /usr/local/bin/kandev") {
|
||||
t.Errorf("expected kandev cli symlink in script")
|
||||
}
|
||||
if !strings.Contains(script, "exec kandev start") {
|
||||
t.Errorf("expected script to launch through kandev start")
|
||||
}
|
||||
if !strings.Contains(script, "--headless") {
|
||||
t.Errorf("expected headless CLI launch")
|
||||
}
|
||||
if strings.Contains(script, "nohup node") {
|
||||
t.Errorf("script should not start web outside the CLI supervisor")
|
||||
}
|
||||
if strings.Contains(script, "/app/apps/backend/bin/kandev >") {
|
||||
t.Errorf("script should not launch the backend binary directly")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,14 +214,14 @@ func TestClient_FirstBootMissesGracefully(t *testing.T) {
|
||||
// configured URL. Block that refresh at the server so it can't
|
||||
// populate the cache before the lookup reads it — otherwise the
|
||||
// "miss" we're asserting races a fast background fetch and flakes
|
||||
// into a hit. The handler unblocks at cleanup so nothing leaks.
|
||||
release := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
<-release
|
||||
_, _ = w.Write([]byte(sampleDataset))
|
||||
// into a hit. The request exits when the lookup context is canceled,
|
||||
// before TempDir cleanup can race a cache write.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
t.Cleanup(func() {
|
||||
close(release)
|
||||
cancel()
|
||||
srv.Close()
|
||||
})
|
||||
c := modelsdev.New(modelsdev.Config{
|
||||
@@ -232,7 +232,7 @@ func TestClient_FirstBootMissesGracefully(t *testing.T) {
|
||||
}, logger.Default())
|
||||
|
||||
// No Refresh — simulating cold boot before any HTTP fetch.
|
||||
if _, ok := c.LookupForModel(context.Background(), "claude-opus-4-7"); ok {
|
||||
if _, ok := c.LookupForModel(ctx, "claude-opus-4-7"); ok {
|
||||
t.Error("expected miss on cold-boot lookup")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -40,6 +41,16 @@ import (
|
||||
//go:embed profiles.yaml
|
||||
var profilesYAML []byte
|
||||
|
||||
var appliedEnvVars = struct {
|
||||
sync.RWMutex
|
||||
names map[string]bool
|
||||
}{names: map[string]bool{}}
|
||||
|
||||
var derivedAppliedEnvVars = map[string]bool{
|
||||
"KANDEV_DEBUG_AGENT_MESSAGES": true,
|
||||
"KANDEV_DEBUG_PPROF_ENABLED": true,
|
||||
}
|
||||
|
||||
// Environment identifies the active runtime profile.
|
||||
type Environment string
|
||||
|
||||
@@ -103,12 +114,37 @@ func ApplyProfile() (count int, env Environment, err error) {
|
||||
if err := os.Setenv(name, value); err != nil {
|
||||
return count, env, fmt.Errorf("setenv %q: %w", name, err)
|
||||
}
|
||||
appliedEnvVars.Lock()
|
||||
appliedEnvVars.names[name] = true
|
||||
appliedEnvVars.Unlock()
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, env, nil
|
||||
}
|
||||
|
||||
// WasApplied reports whether ApplyProfile wrote name into the process
|
||||
// environment. Callers use this to distinguish a profile-supplied default from
|
||||
// a true launcher/shell env var, because DB-backed runtime overrides should
|
||||
// beat the former but never the latter.
|
||||
func WasApplied(name string) bool {
|
||||
appliedEnvVars.RLock()
|
||||
defer appliedEnvVars.RUnlock()
|
||||
return appliedEnvVars.names[name]
|
||||
}
|
||||
|
||||
// MarkApplied records a process env var as runtime/profile-applied rather than
|
||||
// launcher-explicit. This is for startup code that derives secondary env vars
|
||||
// from a profile-backed setting after ApplyProfile has already run.
|
||||
func MarkApplied(name string) {
|
||||
if !derivedAppliedEnvVars[name] {
|
||||
return
|
||||
}
|
||||
appliedEnvVars.Lock()
|
||||
defer appliedEnvVars.Unlock()
|
||||
appliedEnvVars.names[name] = true
|
||||
}
|
||||
|
||||
// parse decodes profiles.yaml into the typed shape. A parse error
|
||||
// here means someone committed a malformed profiles.yaml; fail loud
|
||||
// so CI catches it before a release ships.
|
||||
|
||||
@@ -132,6 +132,24 @@ func TestFeatureFlagDefaults_LowercasesShortName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkApplied_OnlyAllowsKnownDerivedEnvVars(t *testing.T) {
|
||||
clearAppliedEnvVars(t)
|
||||
|
||||
MarkApplied("KANDEV_DEBUG_AGENT_MESSAGES")
|
||||
if !WasApplied("KANDEV_DEBUG_AGENT_MESSAGES") {
|
||||
t.Fatal("KANDEV_DEBUG_AGENT_MESSAGES was not marked applied")
|
||||
}
|
||||
MarkApplied("KANDEV_DEBUG_PPROF_ENABLED")
|
||||
if !WasApplied("KANDEV_DEBUG_PPROF_ENABLED") {
|
||||
t.Fatal("KANDEV_DEBUG_PPROF_ENABLED was not marked applied")
|
||||
}
|
||||
|
||||
MarkApplied("KANDEV_UNRELATED")
|
||||
if WasApplied("KANDEV_UNRELATED") {
|
||||
t.Fatal("KANDEV_UNRELATED was marked applied")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfilesYAML_ContainsRequiredSections is a smoke test that
|
||||
// catches a zero-byte or truncated embed — an embed that silently
|
||||
// picks up an empty file would make every var default to empty,
|
||||
@@ -172,3 +190,16 @@ func clearProfilesYAMLVars(t *testing.T) {
|
||||
t.Cleanup(func() { _ = os.Unsetenv(name) })
|
||||
}
|
||||
}
|
||||
|
||||
func clearAppliedEnvVars(t *testing.T) {
|
||||
t.Helper()
|
||||
appliedEnvVars.Lock()
|
||||
previous := appliedEnvVars.names
|
||||
appliedEnvVars.names = map[string]bool{}
|
||||
appliedEnvVars.Unlock()
|
||||
t.Cleanup(func() {
|
||||
appliedEnvVars.Lock()
|
||||
appliedEnvVars.names = previous
|
||||
appliedEnvVars.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package runtimeflags
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/profiles"
|
||||
)
|
||||
|
||||
func OptionsFromConfig(cfg *config.Config) Options {
|
||||
return Options{
|
||||
DefaultValues: ValuesFromConfig(cfg),
|
||||
RuntimeValues: ValuesFromConfig(cfg),
|
||||
EnvValues: map[string]bool{
|
||||
"KANDEV_FEATURES_OFFICE": isTruthy(os.Getenv("KANDEV_FEATURES_OFFICE")),
|
||||
"KANDEV_DEBUG_DEV_MODE": isTruthy(os.Getenv("KANDEV_DEBUG_DEV_MODE")),
|
||||
"KANDEV_DEBUG_PPROF_ENABLED": isTruthy(os.Getenv("KANDEV_DEBUG_PPROF_ENABLED")),
|
||||
"KANDEV_DEBUG_AGENT_MESSAGES": isTruthy(os.Getenv("KANDEV_DEBUG_AGENT_MESSAGES")),
|
||||
},
|
||||
IsExplicitEnv: func(name string) bool {
|
||||
_, ok := os.LookupEnv(name)
|
||||
return ok && !profiles.WasApplied(name)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ValuesFromConfig(cfg *config.Config) map[string]bool {
|
||||
debugEnabled := cfg.Debug.DevMode || cfg.Debug.PprofEnabled
|
||||
return map[string]bool{
|
||||
"features.office": cfg.Features.Office,
|
||||
"debug.devMode": debugEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
func ApplyStatesToConfig(cfg *config.Config, states []RuntimeFlagState) {
|
||||
for _, state := range states {
|
||||
switch state.Key {
|
||||
case "features.office":
|
||||
cfg.Features.Office = state.EffectiveValue
|
||||
case "debug.devMode":
|
||||
cfg.Debug.DevMode = state.EffectiveValue
|
||||
cfg.Debug.PprofEnabled = state.EffectiveValue
|
||||
if state.EffectiveValue {
|
||||
setIfNotExplicit("KANDEV_DEBUG_AGENT_MESSAGES", "true")
|
||||
setIfNotExplicit("KANDEV_DEBUG_PPROF_ENABLED", "true")
|
||||
} else {
|
||||
unsetIfNotExplicit("KANDEV_DEBUG_AGENT_MESSAGES")
|
||||
unsetIfNotExplicit("KANDEV_DEBUG_PPROF_ENABLED")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RuntimeOptionsFromAppliedConfig(defaults map[string]bool, cfg *config.Config) Options {
|
||||
opts := OptionsFromConfig(cfg)
|
||||
opts.DefaultValues = defaults
|
||||
opts.RuntimeValues = ValuesFromConfig(cfg)
|
||||
return opts
|
||||
}
|
||||
|
||||
func setIfNotExplicit(name, value string) {
|
||||
if _, ok := os.LookupEnv(name); ok && !profiles.WasApplied(name) {
|
||||
return
|
||||
}
|
||||
_ = os.Setenv(name, value)
|
||||
profiles.MarkApplied(name)
|
||||
}
|
||||
|
||||
func unsetIfNotExplicit(name string) {
|
||||
if _, ok := os.LookupEnv(name); ok && !profiles.WasApplied(name) {
|
||||
return
|
||||
}
|
||||
_ = os.Unsetenv(name)
|
||||
}
|
||||
|
||||
func isTruthy(s string) bool {
|
||||
switch strings.ToLower(s) {
|
||||
case "true", "1", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package runtimeflags
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/profiles"
|
||||
)
|
||||
|
||||
func TestApplyStatesToConfigClearsProfileDebugEnvWhenDisabled(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"KANDEV_DEBUG_DEV_MODE",
|
||||
"KANDEV_DEBUG_PPROF_ENABLED",
|
||||
"KANDEV_DEBUG_AGENT_MESSAGES",
|
||||
} {
|
||||
preserveEnv(t, name)
|
||||
}
|
||||
_ = os.Unsetenv("KANDEV_DEBUG_PPROF_ENABLED")
|
||||
_ = os.Unsetenv("KANDEV_DEBUG_AGENT_MESSAGES")
|
||||
t.Setenv("KANDEV_DEBUG_DEV_MODE", "true")
|
||||
|
||||
if _, _, err := profiles.ApplyProfile(); err != nil {
|
||||
t.Fatalf("ApplyProfile: %v", err)
|
||||
}
|
||||
if os.Getenv("KANDEV_DEBUG_AGENT_MESSAGES") != "true" {
|
||||
t.Fatal("profile did not enable agent message debug logs")
|
||||
}
|
||||
|
||||
cfg := &config.Config{}
|
||||
ApplyStatesToConfig(cfg, []RuntimeFlagState{{
|
||||
Key: "debug.devMode",
|
||||
EffectiveValue: false,
|
||||
}})
|
||||
|
||||
if cfg.Debug.DevMode {
|
||||
t.Fatal("Debug.DevMode = true, want false")
|
||||
}
|
||||
if cfg.Debug.PprofEnabled {
|
||||
t.Fatal("Debug.PprofEnabled = true, want false")
|
||||
}
|
||||
if _, ok := os.LookupEnv("KANDEV_DEBUG_AGENT_MESSAGES"); ok {
|
||||
t.Fatal("KANDEV_DEBUG_AGENT_MESSAGES remained set after disabled override")
|
||||
}
|
||||
if _, ok := os.LookupEnv("KANDEV_DEBUG_PPROF_ENABLED"); ok {
|
||||
t.Fatal("KANDEV_DEBUG_PPROF_ENABLED remained set after disabled override")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionsFromConfigParsesUppercaseTruthyEnv(t *testing.T) {
|
||||
preserveEnv(t, "KANDEV_FEATURES_OFFICE")
|
||||
t.Setenv("KANDEV_FEATURES_OFFICE", "TRUE")
|
||||
|
||||
opts := OptionsFromConfig(&config.Config{})
|
||||
|
||||
if !opts.EnvValues["KANDEV_FEATURES_OFFICE"] {
|
||||
t.Fatal("KANDEV_FEATURES_OFFICE TRUE parsed false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyStatesToConfigMarksImpliedDebugEnvAsApplied(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"KANDEV_DEBUG_PPROF_ENABLED",
|
||||
"KANDEV_DEBUG_AGENT_MESSAGES",
|
||||
} {
|
||||
preserveEnv(t, name)
|
||||
}
|
||||
|
||||
cfg := &config.Config{}
|
||||
ApplyStatesToConfig(cfg, []RuntimeFlagState{{
|
||||
Key: "debug.devMode",
|
||||
EffectiveValue: true,
|
||||
}})
|
||||
opts := OptionsFromConfig(cfg)
|
||||
|
||||
for _, name := range []string{
|
||||
"KANDEV_DEBUG_PPROF_ENABLED",
|
||||
"KANDEV_DEBUG_AGENT_MESSAGES",
|
||||
} {
|
||||
if !opts.EnvValues[name] {
|
||||
t.Fatalf("%s was not enabled", name)
|
||||
}
|
||||
if opts.IsExplicitEnv(name) {
|
||||
t.Fatalf("%s reported explicit, want profile-applied", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func preserveEnv(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
value, ok := os.LookupEnv(name)
|
||||
_ = os.Unsetenv(name)
|
||||
t.Cleanup(func() {
|
||||
if ok {
|
||||
_ = os.Setenv(name, value)
|
||||
return
|
||||
}
|
||||
_ = os.Unsetenv(name)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package runtimeflags
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func RegisterRoutes(router gin.IRouter, svc *Service) {
|
||||
h := &Handler{svc: svc}
|
||||
api := router.Group("/api/v1/runtime-flags")
|
||||
api.GET("", h.list)
|
||||
api.PATCH("/:key", h.patch)
|
||||
}
|
||||
|
||||
func (h *Handler) list(c *gin.Context) {
|
||||
states, err := h.svc.ListStates(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read runtime flags"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"flags": states})
|
||||
}
|
||||
|
||||
type patchRequest struct {
|
||||
Override *bool `json:"override"`
|
||||
}
|
||||
|
||||
func (h *Handler) patch(c *gin.Context) {
|
||||
var req patchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid runtime flag payload"})
|
||||
return
|
||||
}
|
||||
}
|
||||
states, err := h.svc.SetOverride(c.Request.Context(), c.Param("key"), req.Override)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
msg := "failed to update runtime flag"
|
||||
if errors.Is(err, ErrEnvLocked) {
|
||||
status = http.StatusConflict
|
||||
msg = "runtime flag is controlled by environment"
|
||||
} else if errors.Is(err, ErrUnknownFlag) {
|
||||
status = http.StatusNotFound
|
||||
msg = "runtime flag not found"
|
||||
}
|
||||
c.JSON(status, gin.H{"error": msg})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"flags": states})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package runtimeflags
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestHandlersPatchRejectsEnvLockedFlag(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := NewService(&memoryStore{}, Options{
|
||||
DefaultValues: map[string]bool{"features.office": false},
|
||||
RuntimeValues: map[string]bool{"features.office": true},
|
||||
EnvValues: map[string]bool{"KANDEV_FEATURES_OFFICE": true},
|
||||
IsExplicitEnv: func(name string) bool { return name == "KANDEV_FEATURES_OFFICE" },
|
||||
})
|
||||
router := gin.New()
|
||||
RegisterRoutes(router, svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/runtime-flags/features.office", strings.NewReader(`{"override":false}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusConflict, rec.Body.String())
|
||||
}
|
||||
assertJSONError(t, rec.Body.String(), "runtime flag is controlled by environment")
|
||||
}
|
||||
|
||||
func TestHandlersPatchUnknownFlag(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := NewService(&memoryStore{}, Options{})
|
||||
router := gin.New()
|
||||
RegisterRoutes(router, svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/runtime-flags/missing.flag", strings.NewReader(`{"override":true}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
assertJSONError(t, rec.Body.String(), "runtime flag not found")
|
||||
}
|
||||
|
||||
func TestHandlersPatchMalformedJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := NewService(&memoryStore{}, Options{})
|
||||
router := gin.New()
|
||||
RegisterRoutes(router, svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/runtime-flags/features.office", strings.NewReader(`{`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
assertJSONError(t, rec.Body.String(), "invalid runtime flag payload")
|
||||
}
|
||||
|
||||
func TestHandlersPatchEmptyBodyClearsOverride(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
store := &memoryStore{values: map[string]bool{"features.office": true}}
|
||||
svc := NewService(store, Options{})
|
||||
router := gin.New()
|
||||
RegisterRoutes(router, svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/runtime-flags/features.office", nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if _, ok := store.values["features.office"]; ok {
|
||||
t.Fatal("features.office override still present after empty-body clear")
|
||||
}
|
||||
}
|
||||
|
||||
func assertJSONError(t *testing.T, body, want string) {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &payload); err != nil {
|
||||
t.Fatalf("unmarshal error body: %v; body=%s", err, body)
|
||||
}
|
||||
if payload.Error != want {
|
||||
t.Fatalf("error = %q, want %q", payload.Error, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package runtimeflags
|
||||
|
||||
var definitions = []RuntimeFlagDefinition{
|
||||
{
|
||||
Key: "features.office",
|
||||
EnvVar: "KANDEV_FEATURES_OFFICE",
|
||||
Kind: KindFeature,
|
||||
Label: "Office mode",
|
||||
Description: "Enables autonomous agent office workflows and related settings.",
|
||||
Stability: StabilityExperimental,
|
||||
RiskLevel: RiskMedium,
|
||||
RiskDescription: "Office mode is still evolving. Workflows, routes, and background automation " +
|
||||
"may change between releases and should be reviewed before relying on them.",
|
||||
RestartRequired: true,
|
||||
Mutable: true,
|
||||
},
|
||||
{
|
||||
Key: "debug.devMode",
|
||||
EnvVar: "KANDEV_DEBUG_DEV_MODE",
|
||||
Kind: KindDebug,
|
||||
Label: "Debug mode",
|
||||
Description: "Enables local diagnostic endpoints and agent message debug logs for troubleshooting backend, agent, and tool-call behavior.",
|
||||
Stability: StabilityStable,
|
||||
RiskLevel: RiskHigh,
|
||||
RiskDescription: "Debug mode can expose local diagnostic endpoints and write prompt, file, " +
|
||||
"and tool-call content to local debug logs. Enable it only on trusted machines.",
|
||||
RestartRequired: true,
|
||||
Mutable: true,
|
||||
ImpliedEnvVars: []string{
|
||||
"KANDEV_DEBUG_PPROF_ENABLED",
|
||||
"KANDEV_DEBUG_AGENT_MESSAGES",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func Definitions() []RuntimeFlagDefinition {
|
||||
out := make([]RuntimeFlagDefinition, len(definitions))
|
||||
copy(out, definitions)
|
||||
return out
|
||||
}
|
||||
|
||||
func DefinitionByKey(key string) (RuntimeFlagDefinition, bool) {
|
||||
for _, def := range definitions {
|
||||
if def.Key == key {
|
||||
return def, true
|
||||
}
|
||||
}
|
||||
return RuntimeFlagDefinition{}, false
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package runtimeflags
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefinitionsIncludeOfficeExperimentalMetadata(t *testing.T) {
|
||||
def, ok := DefinitionByKey("features.office")
|
||||
if !ok {
|
||||
t.Fatal("features.office definition missing")
|
||||
}
|
||||
if def.EnvVar != "KANDEV_FEATURES_OFFICE" {
|
||||
t.Fatalf("EnvVar = %q, want KANDEV_FEATURES_OFFICE", def.EnvVar)
|
||||
}
|
||||
if def.Stability != StabilityExperimental {
|
||||
t.Fatalf("Stability = %q, want %q", def.Stability, StabilityExperimental)
|
||||
}
|
||||
if def.RiskDescription == "" {
|
||||
t.Fatal("RiskDescription empty")
|
||||
}
|
||||
if !def.RestartRequired {
|
||||
t.Fatal("RestartRequired = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefinitionsExposeSingleUserFacingDebugToggle(t *testing.T) {
|
||||
def, ok := DefinitionByKey("debug.devMode")
|
||||
if !ok {
|
||||
t.Fatal("debug.devMode definition missing")
|
||||
}
|
||||
if def.EnvVar != "KANDEV_DEBUG_DEV_MODE" {
|
||||
t.Fatalf("EnvVar = %q, want KANDEV_DEBUG_DEV_MODE", def.EnvVar)
|
||||
}
|
||||
if len(def.ImpliedEnvVars) == 0 {
|
||||
t.Fatal("Debug mode should imply subordinate debug env vars")
|
||||
}
|
||||
if _, ok := DefinitionByKey("debug.agentMessages"); ok {
|
||||
t.Fatal("debug.agentMessages must not be a top-level user-facing toggle")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package runtimeflags
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEnvLocked = errors.New("runtime flag is controlled by environment")
|
||||
ErrStoreUnset = errors.New("runtime flag store is not configured")
|
||||
ErrUnknownFlag = errors.New("unknown runtime flag")
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
DefaultValues map[string]bool
|
||||
RuntimeValues map[string]bool
|
||||
EnvValues map[string]bool
|
||||
IsExplicitEnv func(name string) bool
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
opts Options
|
||||
}
|
||||
|
||||
func NewService(store Store, opts Options) *Service {
|
||||
if opts.IsExplicitEnv == nil {
|
||||
opts.IsExplicitEnv = func(string) bool { return false }
|
||||
}
|
||||
return &Service{store: store, opts: opts}
|
||||
}
|
||||
|
||||
func (s *Service) ListStates(ctx context.Context) ([]RuntimeFlagState, error) {
|
||||
if s.store == nil {
|
||||
return nil, ErrStoreUnset
|
||||
}
|
||||
overrides, err := s.store.ListOverrides(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defs := Definitions()
|
||||
sort.Slice(defs, func(i, j int) bool { return defs[i].Key < defs[j].Key })
|
||||
states := make([]RuntimeFlagState, 0, len(defs))
|
||||
for _, def := range defs {
|
||||
states = append(states, s.stateFor(def, overrides))
|
||||
}
|
||||
return states, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetOverride(ctx context.Context, key string, value *bool) ([]RuntimeFlagState, error) {
|
||||
if s.store == nil {
|
||||
return nil, ErrStoreUnset
|
||||
}
|
||||
def, ok := DefinitionByKey(key)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownFlag, key)
|
||||
}
|
||||
if s.isEnvLocked(def) {
|
||||
return nil, ErrEnvLocked
|
||||
}
|
||||
if value == nil {
|
||||
if err := s.store.DeleteOverride(ctx, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err := s.store.SetOverride(ctx, key, *value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ListStates(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) stateFor(def RuntimeFlagDefinition, overrides map[string]bool) RuntimeFlagState {
|
||||
defaultValue := s.opts.DefaultValues[def.Key]
|
||||
runtimeValue := s.opts.RuntimeValues[def.Key]
|
||||
effectiveValue := defaultValue
|
||||
source := SourceProfile
|
||||
var overrideValue *bool
|
||||
|
||||
if value, ok := overrides[def.Key]; ok {
|
||||
v := value
|
||||
overrideValue = &v
|
||||
effectiveValue = value
|
||||
source = SourceOverride
|
||||
}
|
||||
if value, locked := s.envLockedValue(def, runtimeValue); locked {
|
||||
effectiveValue = value
|
||||
source = SourceEnv
|
||||
}
|
||||
if !defaultValue && source == SourceProfile {
|
||||
source = SourceDefault
|
||||
}
|
||||
|
||||
return RuntimeFlagState{
|
||||
Key: def.Key,
|
||||
Kind: def.Kind,
|
||||
Label: def.Label,
|
||||
Description: def.Description,
|
||||
Stability: def.Stability,
|
||||
RiskLevel: def.RiskLevel,
|
||||
RiskDescription: def.RiskDescription,
|
||||
EffectiveValue: effectiveValue,
|
||||
DefaultValue: defaultValue,
|
||||
OverrideValue: overrideValue,
|
||||
Source: source,
|
||||
EnvVar: def.EnvVar,
|
||||
EnvLocked: source == SourceEnv,
|
||||
RestartRequired: def.RestartRequired,
|
||||
RequiresRestartToApply: def.RestartRequired && effectiveValue != runtimeValue,
|
||||
Mutable: def.Mutable,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) isEnvLocked(def RuntimeFlagDefinition) bool {
|
||||
_, locked := s.envLockedValue(def, false)
|
||||
return locked
|
||||
}
|
||||
|
||||
func (s *Service) envLockedValue(def RuntimeFlagDefinition, fallback bool) (bool, bool) {
|
||||
if s.opts.IsExplicitEnv(def.EnvVar) {
|
||||
return s.envValue(def.EnvVar, fallback), true
|
||||
}
|
||||
for _, name := range def.ImpliedEnvVars {
|
||||
if s.opts.IsExplicitEnv(name) {
|
||||
return s.envValue(name, fallback), true
|
||||
}
|
||||
}
|
||||
return fallback, false
|
||||
}
|
||||
|
||||
func (s *Service) envValue(name string, fallback bool) bool {
|
||||
if value, ok := s.opts.EnvValues[name]; ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package runtimeflags
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type memoryStore struct {
|
||||
values map[string]bool
|
||||
}
|
||||
|
||||
func (m *memoryStore) ListOverrides(context.Context) (map[string]bool, error) {
|
||||
out := make(map[string]bool, len(m.values))
|
||||
for k, v := range m.values {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) SetOverride(_ context.Context, key string, value bool) error {
|
||||
if m.values == nil {
|
||||
m.values = map[string]bool{}
|
||||
}
|
||||
m.values[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) DeleteOverride(_ context.Context, key string) error {
|
||||
delete(m.values, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestServiceOverrideWinsOverProfileDefault(t *testing.T) {
|
||||
svc := NewService(&memoryStore{values: map[string]bool{"features.office": true}}, Options{
|
||||
DefaultValues: map[string]bool{"features.office": false},
|
||||
RuntimeValues: map[string]bool{"features.office": false},
|
||||
IsExplicitEnv: func(string) bool { return false },
|
||||
})
|
||||
states, err := svc.ListStates(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListStates: %v", err)
|
||||
}
|
||||
office := stateByKey(t, states, "features.office")
|
||||
if !office.EffectiveValue {
|
||||
t.Fatal("EffectiveValue = false, want true")
|
||||
}
|
||||
if office.Source != SourceOverride {
|
||||
t.Fatalf("Source = %q, want %q", office.Source, SourceOverride)
|
||||
}
|
||||
if !office.RequiresRestartToApply {
|
||||
t.Fatal("RequiresRestartToApply = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceExplicitEnvLocksOverride(t *testing.T) {
|
||||
svc := NewService(&memoryStore{values: map[string]bool{"features.office": false}}, Options{
|
||||
DefaultValues: map[string]bool{"features.office": false},
|
||||
RuntimeValues: map[string]bool{"features.office": true},
|
||||
EnvValues: map[string]bool{"KANDEV_FEATURES_OFFICE": true},
|
||||
IsExplicitEnv: func(name string) bool { return name == "KANDEV_FEATURES_OFFICE" },
|
||||
})
|
||||
states, err := svc.ListStates(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListStates: %v", err)
|
||||
}
|
||||
office := stateByKey(t, states, "features.office")
|
||||
if !office.EffectiveValue {
|
||||
t.Fatal("EffectiveValue = false, want true")
|
||||
}
|
||||
if office.Source != SourceEnv {
|
||||
t.Fatalf("Source = %q, want %q", office.Source, SourceEnv)
|
||||
}
|
||||
if !office.EnvLocked {
|
||||
t.Fatal("EnvLocked = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceExplicitImpliedEnvLocksDebugMode(t *testing.T) {
|
||||
svc := NewService(&memoryStore{values: map[string]bool{"debug.devMode": false}}, Options{
|
||||
DefaultValues: map[string]bool{"debug.devMode": false},
|
||||
RuntimeValues: map[string]bool{"debug.devMode": false},
|
||||
EnvValues: map[string]bool{
|
||||
"KANDEV_DEBUG_DEV_MODE": false,
|
||||
"KANDEV_DEBUG_PPROF_ENABLED": true,
|
||||
},
|
||||
IsExplicitEnv: func(name string) bool { return name == "KANDEV_DEBUG_PPROF_ENABLED" },
|
||||
})
|
||||
states, err := svc.ListStates(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListStates: %v", err)
|
||||
}
|
||||
debug := stateByKey(t, states, "debug.devMode")
|
||||
if !debug.EffectiveValue {
|
||||
t.Fatal("EffectiveValue = false, want true")
|
||||
}
|
||||
if debug.Source != SourceEnv {
|
||||
t.Fatalf("Source = %q, want %q", debug.Source, SourceEnv)
|
||||
}
|
||||
if !debug.EnvLocked {
|
||||
t.Fatal("EnvLocked = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceNilStoreFailsFast(t *testing.T) {
|
||||
svc := NewService(nil, Options{})
|
||||
if _, err := svc.ListStates(context.Background()); !errors.Is(err, ErrStoreUnset) {
|
||||
t.Fatalf("ListStates error = %v, want %v", err, ErrStoreUnset)
|
||||
}
|
||||
if _, err := svc.SetOverride(context.Background(), "features.office", nil); !errors.Is(err, ErrStoreUnset) {
|
||||
t.Fatalf("SetOverride error = %v, want %v", err, ErrStoreUnset)
|
||||
}
|
||||
}
|
||||
|
||||
func stateByKey(t *testing.T, states []RuntimeFlagState, key string) RuntimeFlagState {
|
||||
t.Helper()
|
||||
for _, state := range states {
|
||||
if state.Key == key {
|
||||
return state
|
||||
}
|
||||
}
|
||||
t.Fatalf("state %q missing", key)
|
||||
return RuntimeFlagState{}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package runtimeflags
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type SQLiteStore struct {
|
||||
writer *sqlx.DB
|
||||
reader *sqlx.DB
|
||||
}
|
||||
|
||||
func NewSQLiteStore(writer, reader *sqlx.DB) (*SQLiteStore, error) {
|
||||
store := &SQLiteStore{writer: writer, reader: reader}
|
||||
if err := store.initSchema(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) initSchema() error {
|
||||
_, err := s.writer.Exec(`CREATE TABLE IF NOT EXISTS runtime_flag_overrides (
|
||||
key TEXT PRIMARY KEY,
|
||||
value INTEGER NOT NULL CHECK (value IN (0, 1)),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create runtime_flag_overrides: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) ListOverrides(ctx context.Context) (map[string]bool, error) {
|
||||
rows, err := s.reader.QueryxContext(ctx, `SELECT key, value FROM runtime_flag_overrides`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list runtime flag overrides: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var value int
|
||||
if err := rows.Scan(&key, &value); err != nil {
|
||||
return nil, fmt.Errorf("scan runtime flag override: %w", err)
|
||||
}
|
||||
out[key] = value != 0
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate runtime flag overrides: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) SetOverride(ctx context.Context, key string, value bool) error {
|
||||
now := time.Now().UTC()
|
||||
intValue := 0
|
||||
if value {
|
||||
intValue = 1
|
||||
}
|
||||
_, err := s.writer.ExecContext(ctx, `INSERT INTO runtime_flag_overrides (key, value, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||
key, intValue, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set runtime flag override %q: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) DeleteOverride(ctx context.Context, key string) error {
|
||||
if _, err := s.writer.ExecContext(ctx, `DELETE FROM runtime_flag_overrides WHERE key = ?`, key); err != nil {
|
||||
return fmt.Errorf("delete runtime flag override %q: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package runtimeflags
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func TestSQLiteStoreRoundTripsFalseOverride(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := store.SetOverride(ctx, "features.office", false); err != nil {
|
||||
t.Fatalf("SetOverride false: %v", err)
|
||||
}
|
||||
overrides, err := store.ListOverrides(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListOverrides: %v", err)
|
||||
}
|
||||
value, ok := overrides["features.office"]
|
||||
if !ok {
|
||||
t.Fatal("features.office override missing")
|
||||
}
|
||||
if value {
|
||||
t.Fatal("features.office override = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreDeleteOverrideRestoresMissing(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := store.SetOverride(ctx, "features.office", true); err != nil {
|
||||
t.Fatalf("SetOverride true: %v", err)
|
||||
}
|
||||
if err := store.DeleteOverride(ctx, "features.office"); err != nil {
|
||||
t.Fatalf("DeleteOverride: %v", err)
|
||||
}
|
||||
overrides, err := store.ListOverrides(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListOverrides: %v", err)
|
||||
}
|
||||
if _, ok := overrides["features.office"]; ok {
|
||||
t.Fatal("features.office override still present after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) *SQLiteStore {
|
||||
t.Helper()
|
||||
db, err := sqlx.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
store, err := NewSQLiteStore(db, db)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package runtimeflags
|
||||
|
||||
import "context"
|
||||
|
||||
type RuntimeFlagKind string
|
||||
|
||||
const (
|
||||
KindFeature RuntimeFlagKind = "feature"
|
||||
KindDebug RuntimeFlagKind = "debug"
|
||||
)
|
||||
|
||||
type RuntimeFlagSource string
|
||||
|
||||
const (
|
||||
SourceEnv RuntimeFlagSource = "env"
|
||||
SourceOverride RuntimeFlagSource = "override"
|
||||
SourceProfile RuntimeFlagSource = "profile"
|
||||
SourceDefault RuntimeFlagSource = "default"
|
||||
)
|
||||
|
||||
type RuntimeFlagStability string
|
||||
|
||||
const (
|
||||
StabilityStable RuntimeFlagStability = "stable"
|
||||
StabilityBeta RuntimeFlagStability = "beta"
|
||||
StabilityExperimental RuntimeFlagStability = "experimental"
|
||||
)
|
||||
|
||||
type RuntimeFlagRiskLevel string
|
||||
|
||||
const (
|
||||
RiskLow RuntimeFlagRiskLevel = "low"
|
||||
RiskMedium RuntimeFlagRiskLevel = "medium"
|
||||
RiskHigh RuntimeFlagRiskLevel = "high"
|
||||
)
|
||||
|
||||
type RuntimeFlagDefinition struct {
|
||||
Key string `json:"key"`
|
||||
EnvVar string `json:"env_var"`
|
||||
Kind RuntimeFlagKind `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Stability RuntimeFlagStability `json:"stability"`
|
||||
RiskLevel RuntimeFlagRiskLevel `json:"risk_level"`
|
||||
RiskDescription string `json:"risk_description"`
|
||||
RestartRequired bool `json:"restart_required"`
|
||||
Mutable bool `json:"mutable"`
|
||||
ImpliedEnvVars []string `json:"-"`
|
||||
}
|
||||
|
||||
type RuntimeFlagState struct {
|
||||
Key string `json:"key"`
|
||||
Kind RuntimeFlagKind `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Stability RuntimeFlagStability `json:"stability"`
|
||||
RiskLevel RuntimeFlagRiskLevel `json:"risk_level"`
|
||||
RiskDescription string `json:"risk_description"`
|
||||
EffectiveValue bool `json:"effective_value"`
|
||||
DefaultValue bool `json:"default_value"`
|
||||
OverrideValue *bool `json:"override_value"`
|
||||
Source RuntimeFlagSource `json:"source"`
|
||||
EnvVar string `json:"env_var"`
|
||||
EnvLocked bool `json:"env_locked"`
|
||||
RestartRequired bool `json:"restart_required"`
|
||||
RequiresRestartToApply bool `json:"requires_restart_to_apply"`
|
||||
Mutable bool `json:"mutable"`
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
ListOverrides(ctx context.Context) (map[string]bool, error)
|
||||
SetOverride(ctx context.Context, key string, value bool) error
|
||||
DeleteOverride(ctx context.Context, key string) error
|
||||
}
|
||||
@@ -4,8 +4,12 @@
|
||||
package info
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -18,6 +22,8 @@ type Response struct {
|
||||
GoVersion string `json:"go_version"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
BootID string `json:"boot_id"`
|
||||
StartedAt string `json:"started_at"`
|
||||
}
|
||||
|
||||
// Service composes the static build info; instantiated once at boot from
|
||||
@@ -27,11 +33,19 @@ type Service struct {
|
||||
Version string
|
||||
Commit string
|
||||
BuildTime string
|
||||
BootID string
|
||||
StartedAt time.Time
|
||||
}
|
||||
|
||||
// NewService constructs a Service from the ldflag-injected build values.
|
||||
func NewService(version, commit, buildTime string) *Service {
|
||||
return &Service{Version: version, Commit: commit, BuildTime: buildTime}
|
||||
return &Service{
|
||||
Version: version,
|
||||
Commit: commit,
|
||||
BuildTime: buildTime,
|
||||
BootID: newBootID(),
|
||||
StartedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// Info renders the response payload.
|
||||
@@ -43,6 +57,8 @@ func (s *Service) Info() Response {
|
||||
GoVersion: runtime.Version(),
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
BootID: s.BootID,
|
||||
StartedAt: s.StartedAt.Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,3 +68,11 @@ func Handler(s *Service) gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, s.Info())
|
||||
}
|
||||
}
|
||||
|
||||
func newBootID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
binary.BigEndian.PutUint64(b[8:], uint64(time.Now().UTC().UnixNano()))
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package info
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
@@ -30,6 +31,15 @@ func TestInfo_ReturnsConfiguredAndRuntimeFields(t *testing.T) {
|
||||
if got.Arch != runtime.GOARCH {
|
||||
t.Errorf("Arch = %q", got.Arch)
|
||||
}
|
||||
if got.BootID == "" {
|
||||
t.Fatal("BootID empty")
|
||||
}
|
||||
if got.StartedAt == "" {
|
||||
t.Fatal("StartedAt empty")
|
||||
}
|
||||
if again := s.Info(); again.BootID != got.BootID {
|
||||
t.Fatalf("BootID changed within process: %q != %q", again.BootID, got.BootID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_RendersJSON(t *testing.T) {
|
||||
@@ -52,4 +62,17 @@ func TestHandler_RendersJSON(t *testing.T) {
|
||||
if resp.Version != "v1.2.3" {
|
||||
t.Errorf("Version = %q", resp.Version)
|
||||
}
|
||||
if resp.BootID == "" {
|
||||
t.Fatal("BootID empty")
|
||||
}
|
||||
if resp.StartedAt == "" {
|
||||
t.Fatal("StartedAt empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBootID_ReturnsHexEncodedBytes(t *testing.T) {
|
||||
got := newBootID()
|
||||
if matched := regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(got); !matched {
|
||||
t.Fatalf("BootID = %q, want 32 lowercase hex chars", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package restart
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func HandleCapability(manager Manager) gin.HandlerFunc {
|
||||
if manager == nil {
|
||||
manager = NewUnsupportedManager("")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, manager.Capability(c.Request.Context()))
|
||||
}
|
||||
}
|
||||
|
||||
func HandleRequest(manager Manager) gin.HandlerFunc {
|
||||
if manager == nil {
|
||||
manager = NewUnsupportedManager("")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
resp, err := manager.RequestRestart(c.Request.Context())
|
||||
if errors.Is(err, ErrUnsupported) {
|
||||
c.JSON(http.StatusNotImplemented, resp)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, RestartResponse{
|
||||
Accepted: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, resp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package restart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestHandleCapabilityReportsUnsupportedManager(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/system/restart-capability", HandleCapability(NewUnsupportedManager("")))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/restart-capability", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var body Capability
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body.Supported {
|
||||
t.Fatal("Supported = true, want false")
|
||||
}
|
||||
if body.Mode != ModeManual {
|
||||
t.Fatalf("Mode = %q, want %q", body.Mode, ModeManual)
|
||||
}
|
||||
if body.Adapter != AdapterUnsupported {
|
||||
t.Fatalf("Adapter = %q, want %q", body.Adapter, AdapterUnsupported)
|
||||
}
|
||||
if body.Reason == "" {
|
||||
t.Fatal("Reason empty, want manual restart guidance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestRejectsUnsupportedRestart(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/system/restart", HandleRequest(NewUnsupportedManager("")))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/restart", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNotImplemented, rec.Body.String())
|
||||
}
|
||||
var body RestartResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body.Accepted {
|
||||
t.Fatal("Accepted = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestDelegatesToSupportedManager(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
mgr := &fakeManager{
|
||||
resp: RestartResponse{Accepted: true, Message: "Restart requested."},
|
||||
}
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/system/restart", HandleRequest(mgr))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/restart", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
|
||||
}
|
||||
if mgr.requestCalls != 1 {
|
||||
t.Fatalf("requestCalls = %d, want 1", mgr.requestCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestReportsManagerError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/system/restart", HandleRequest(&fakeManager{err: errors.New("boom")}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/restart", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusInternalServerError, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type fakeManager struct {
|
||||
resp RestartResponse
|
||||
err error
|
||||
requestCalls int
|
||||
}
|
||||
|
||||
func (m *fakeManager) Capability(context.Context) Capability {
|
||||
return Capability{
|
||||
Supported: true,
|
||||
Mode: ModeSupervisor,
|
||||
Adapter: AdapterSupervisor,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *fakeManager) RequestRestart(context.Context) (RestartResponse, error) {
|
||||
m.requestCalls++
|
||||
return m.resp, m.err
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package restart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type Capability struct {
|
||||
Supported bool `json:"supported"`
|
||||
Mode string `json:"mode"`
|
||||
Adapter string `json:"adapter"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type RestartResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type Manager interface {
|
||||
Capability(ctx context.Context) Capability
|
||||
RequestRestart(ctx context.Context) (RestartResponse, error)
|
||||
}
|
||||
|
||||
const (
|
||||
AdapterUnsupported = "unsupported"
|
||||
AdapterSupervisor = "supervisor"
|
||||
|
||||
ModeManual = "manual"
|
||||
ModeSupervisor = "supervisor"
|
||||
|
||||
unsupportedReason = "Automatic restart is not available for this launch mode. Restart Kandev from the terminal or service manager."
|
||||
)
|
||||
|
||||
var ErrUnsupported = errors.New("restart unsupported")
|
||||
@@ -0,0 +1,132 @@
|
||||
package restart
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
EnvSupervisorSocket = "KANDEV_SUPERVISOR_SOCKET"
|
||||
EnvSupervisorManifest = "KANDEV_SUPERVISOR_MANIFEST"
|
||||
EnvRestartAdapter = "KANDEV_RESTART_ADAPTER"
|
||||
|
||||
controlTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
type SupervisorManager struct {
|
||||
SocketPath string
|
||||
Manifest string
|
||||
Dial func(ctx context.Context, network, address string) (net.Conn, error)
|
||||
}
|
||||
|
||||
func NewManagerFromEnv() Manager {
|
||||
adapter := os.Getenv(EnvRestartAdapter)
|
||||
socket := os.Getenv(EnvSupervisorSocket)
|
||||
if adapter == AdapterSupervisor && socket == "" {
|
||||
return NewUnsupportedManager(fmt.Sprintf("%s is required when %s=%s.", EnvSupervisorSocket, EnvRestartAdapter, AdapterSupervisor))
|
||||
}
|
||||
if adapter == AdapterSupervisor || socket != "" {
|
||||
return NewSupervisorManager(socket, os.Getenv(EnvSupervisorManifest))
|
||||
}
|
||||
return NewUnsupportedManager("")
|
||||
}
|
||||
|
||||
func NewSupervisorManager(socketPath, manifest string) *SupervisorManager {
|
||||
return &SupervisorManager{
|
||||
SocketPath: socketPath,
|
||||
Manifest: manifest,
|
||||
Dial: (&net.Dialer{
|
||||
Timeout: controlTimeout,
|
||||
}).DialContext,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SupervisorManager) Capability(ctx context.Context) Capability {
|
||||
if runtime.GOOS == "windows" {
|
||||
return m.unsupported("Supervisor restart is not available on Windows yet.")
|
||||
}
|
||||
if m.SocketPath == "" {
|
||||
return m.unsupported("Supervisor restart is not configured for this launch mode.")
|
||||
}
|
||||
if err := m.probe(ctx); err != nil {
|
||||
return m.unsupported("Supervisor restart is configured but unavailable: " + err.Error())
|
||||
}
|
||||
return Capability{
|
||||
Supported: true,
|
||||
Mode: ModeSupervisor,
|
||||
Adapter: AdapterSupervisor,
|
||||
Details: map[string]interface{}{
|
||||
"restart_requires_ui_poll": true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SupervisorManager) RequestRestart(ctx context.Context) (RestartResponse, error) {
|
||||
if cap := m.Capability(ctx); !cap.Supported {
|
||||
return RestartResponse{Accepted: false, Message: cap.Reason}, ErrUnsupported
|
||||
}
|
||||
resp, err := m.send(ctx, map[string]string{"action": "restart"})
|
||||
if err != nil {
|
||||
return RestartResponse{}, err
|
||||
}
|
||||
if !resp.Accepted {
|
||||
return RestartResponse{Accepted: false, Message: resp.Message}, fmt.Errorf("supervisor rejected restart: %s", resp.Message)
|
||||
}
|
||||
return RestartResponse{Accepted: true, Message: "Restart requested. Kandev will be unavailable briefly."}, nil
|
||||
}
|
||||
|
||||
func (m *SupervisorManager) probe(ctx context.Context) error {
|
||||
conn, err := m.dial(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.Close()
|
||||
}
|
||||
|
||||
func (m *SupervisorManager) send(ctx context.Context, req map[string]string) (RestartResponse, error) {
|
||||
conn, err := m.dial(ctx)
|
||||
if err != nil {
|
||||
return RestartResponse{}, err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
} else {
|
||||
_ = conn.SetDeadline(time.Now().Add(controlTimeout))
|
||||
}
|
||||
if err := json.NewEncoder(conn).Encode(req); err != nil {
|
||||
return RestartResponse{}, err
|
||||
}
|
||||
line, err := bufio.NewReader(conn).ReadBytes('\n')
|
||||
if err != nil {
|
||||
return RestartResponse{}, err
|
||||
}
|
||||
var resp RestartResponse
|
||||
if err := json.Unmarshal(line, &resp); err != nil {
|
||||
return RestartResponse{}, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (m *SupervisorManager) dial(ctx context.Context) (net.Conn, error) {
|
||||
dial := m.Dial
|
||||
if dial == nil {
|
||||
dial = (&net.Dialer{Timeout: controlTimeout}).DialContext
|
||||
}
|
||||
return dial(ctx, "unix", m.SocketPath)
|
||||
}
|
||||
|
||||
func (m *SupervisorManager) unsupported(reason string) Capability {
|
||||
return Capability{
|
||||
Supported: false,
|
||||
Mode: ModeManual,
|
||||
Adapter: AdapterUnsupported,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package restart
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSupervisorManagerReportsUnsupportedWithoutSocket(t *testing.T) {
|
||||
mgr := NewSupervisorManager("", "")
|
||||
cap := mgr.Capability(context.Background())
|
||||
|
||||
if cap.Supported {
|
||||
t.Fatal("Supported = true, want false")
|
||||
}
|
||||
if cap.Adapter != AdapterUnsupported {
|
||||
t.Fatalf("Adapter = %q, want %q", cap.Adapter, AdapterUnsupported)
|
||||
}
|
||||
if cap.Reason == "" {
|
||||
t.Fatal("Reason empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewManagerFromEnvReportsMissingSupervisorSocket(t *testing.T) {
|
||||
t.Setenv(EnvRestartAdapter, AdapterSupervisor)
|
||||
t.Setenv(EnvSupervisorSocket, "")
|
||||
|
||||
cap := NewManagerFromEnv().Capability(context.Background())
|
||||
if cap.Supported {
|
||||
t.Fatal("Supported = true, want false")
|
||||
}
|
||||
if !strings.Contains(cap.Reason, EnvSupervisorSocket) {
|
||||
t.Fatalf("Reason = %q, want mention %s", cap.Reason, EnvSupervisorSocket)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupervisorManagerRequestsRestart(t *testing.T) {
|
||||
socket := filepath.Join(os.TempDir(), "kdv-supervisor-test.sock")
|
||||
_ = os.Remove(socket)
|
||||
t.Cleanup(func() { _ = os.Remove(socket) })
|
||||
requests := make(chan map[string]string, 1)
|
||||
closeServer := startFakeSupervisor(t, socket, requests)
|
||||
defer closeServer()
|
||||
|
||||
mgr := NewSupervisorManager(socket, "")
|
||||
cap := mgr.Capability(context.Background())
|
||||
if !cap.Supported {
|
||||
t.Fatalf("Supported = false, reason=%q", cap.Reason)
|
||||
}
|
||||
if cap.Adapter != AdapterSupervisor {
|
||||
t.Fatalf("Adapter = %q, want %q", cap.Adapter, AdapterSupervisor)
|
||||
}
|
||||
|
||||
resp, err := mgr.RequestRestart(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("RequestRestart: %v", err)
|
||||
}
|
||||
if !resp.Accepted {
|
||||
t.Fatalf("Accepted = false, message=%q", resp.Message)
|
||||
}
|
||||
req := <-requests
|
||||
if req["action"] != "restart" {
|
||||
t.Fatalf("action = %q, want restart", req["action"])
|
||||
}
|
||||
}
|
||||
|
||||
func startFakeSupervisor(
|
||||
t *testing.T,
|
||||
socket string,
|
||||
requests chan<- map[string]string,
|
||||
) func() {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("unix", socket)
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go handleFakeSupervisorConn(conn, requests)
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
_ = ln.Close()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func handleFakeSupervisorConn(conn net.Conn, requests chan<- map[string]string) {
|
||||
defer func() { _ = conn.Close() }()
|
||||
line, err := bufio.NewReader(conn).ReadBytes('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var req map[string]string
|
||||
if err := json.Unmarshal(line, &req); err == nil {
|
||||
requests <- req
|
||||
}
|
||||
_ = json.NewEncoder(conn).Encode(RestartResponse{
|
||||
Accepted: true,
|
||||
Message: "Restart accepted",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package restart
|
||||
|
||||
import "context"
|
||||
|
||||
type UnsupportedManager struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func NewUnsupportedManager(reason string) *UnsupportedManager {
|
||||
return &UnsupportedManager{Reason: reason}
|
||||
}
|
||||
|
||||
func (m *UnsupportedManager) Capability(context.Context) Capability {
|
||||
return Capability{
|
||||
Supported: false,
|
||||
Mode: ModeManual,
|
||||
Adapter: AdapterUnsupported,
|
||||
Reason: m.reason(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *UnsupportedManager) RequestRestart(context.Context) (RestartResponse, error) {
|
||||
return RestartResponse{
|
||||
Accepted: false,
|
||||
Message: m.reason(),
|
||||
}, ErrUnsupported
|
||||
}
|
||||
|
||||
func (m *UnsupportedManager) reason() string {
|
||||
if m != nil && m.Reason != "" {
|
||||
return m.Reason
|
||||
}
|
||||
return unsupportedReason
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ async function main(): Promise<void> {
|
||||
webPort,
|
||||
verbose: options.verbose,
|
||||
debug: options.debug,
|
||||
headless: options.headless,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
+10
-13
@@ -1,4 +1,3 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -7,13 +6,8 @@ import { devKandevHome, HEALTH_TIMEOUT_MS_DEV } from "./constants";
|
||||
import { resolveHealthTimeoutMs, waitForHealth, waitForUrlReady } from "./health";
|
||||
import { isInsideKandevTask } from "./kandev-env";
|
||||
import { createProcessSupervisor } from "./process";
|
||||
import {
|
||||
attachBackendExitHandler,
|
||||
buildBackendEnv,
|
||||
buildWebEnv,
|
||||
logStartupInfo,
|
||||
pickPorts,
|
||||
} from "./shared";
|
||||
import { buildBackendEnv, buildWebEnv, logStartupInfo, pickPorts } from "./shared";
|
||||
import { launchRestartableBackend } from "./supervisor/backend";
|
||||
import { launchWebApp, openBrowser } from "./web";
|
||||
|
||||
export type DevOptions = {
|
||||
@@ -64,18 +58,21 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr
|
||||
path.join("apps", "backend"),
|
||||
"dev",
|
||||
]);
|
||||
const backendProc = spawn(backendCmd, backendArgs, {
|
||||
const backend = await launchRestartableBackend({
|
||||
command: backendCmd,
|
||||
args: backendArgs,
|
||||
cwd: repoRoot,
|
||||
env: backendEnv,
|
||||
homeDir: backendEnv.KANDEV_HOME_DIR ?? devKandevHome(repoRoot),
|
||||
ports,
|
||||
mode: "dev",
|
||||
stdio: "inherit",
|
||||
supervisor,
|
||||
});
|
||||
supervisor.children.push(backendProc);
|
||||
|
||||
attachBackendExitHandler(backendProc, supervisor);
|
||||
|
||||
const healthTimeoutMs = resolveHealthTimeoutMs(HEALTH_TIMEOUT_MS_DEV);
|
||||
console.log("[kandev] starting backend...");
|
||||
await waitForHealth(ports.backendUrl, backendProc, healthTimeoutMs);
|
||||
await waitForHealth(ports.backendUrl, backend.proc, healthTimeoutMs);
|
||||
console.log(`[kandev] backend ready at ${ports.backendUrl}`);
|
||||
|
||||
console.log("[kandev] starting web...");
|
||||
|
||||
+19
-8
@@ -11,6 +11,7 @@ import {
|
||||
resolveCacheDir,
|
||||
resolveDataDir,
|
||||
resolveDatabasePath,
|
||||
resolveKandevHomeDir,
|
||||
} from "./constants";
|
||||
import { ensureAsset, getRelease } from "./github";
|
||||
import { resolveHealthTimeoutMs, waitForHealth, waitForUrlReady } from "./health";
|
||||
@@ -19,7 +20,8 @@ import { sortVersionsDesc } from "./version";
|
||||
import { pickAvailablePort } from "./ports";
|
||||
import { createProcessSupervisor } from "./process";
|
||||
import { resolveRuntime, validateBundle } from "./runtime";
|
||||
import { attachBackendExitHandler, buildBackendEnv, buildWebEnv, logStartupInfo } from "./shared";
|
||||
import { buildBackendEnv, buildWebEnv, logStartupInfo } from "./shared";
|
||||
import { launchRestartableBackend } from "./supervisor/backend";
|
||||
import { launchWebApp, openBrowser } from "./web";
|
||||
|
||||
export type RunOptions = {
|
||||
@@ -254,12 +256,12 @@ export function attachRingBuffer(
|
||||
return () => buf;
|
||||
}
|
||||
|
||||
function launchBundle(prepared: PreparedBundle): {
|
||||
async function launchBundle(prepared: PreparedBundle): Promise<{
|
||||
supervisor: ReturnType<typeof createProcessSupervisor>;
|
||||
backendProc: ReturnType<typeof spawn>;
|
||||
webServerPath: string;
|
||||
dumpBackendLogs: () => void;
|
||||
} {
|
||||
}> {
|
||||
logStartupInfo({
|
||||
header: `release: ${prepared.releaseTag}`,
|
||||
ports: {
|
||||
@@ -275,12 +277,23 @@ function launchBundle(prepared: PreparedBundle): {
|
||||
const supervisor = createProcessSupervisor();
|
||||
supervisor.attachSignalHandlers();
|
||||
|
||||
const backendProc = spawn(prepared.backendBin, [], {
|
||||
const backend = await launchRestartableBackend({
|
||||
command: prepared.backendBin,
|
||||
args: [],
|
||||
cwd: path.dirname(prepared.backendBin),
|
||||
env: prepared.backendEnv,
|
||||
homeDir: resolveKandevHomeDir(),
|
||||
ports: {
|
||||
backendPort: Number(prepared.backendEnv.KANDEV_SERVER_PORT),
|
||||
webPort: prepared.webPort,
|
||||
agentctlPort: prepared.agentctlPort,
|
||||
backendUrl: prepared.backendUrl,
|
||||
},
|
||||
mode: "run",
|
||||
stdio: prepared.showOutput ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "inherit"],
|
||||
supervisor,
|
||||
});
|
||||
supervisor.children.push(backendProc);
|
||||
const backendProc = backend.proc;
|
||||
|
||||
const readBuffered = prepared.showOutput ? () => "" : attachRingBuffer(backendProc.stdout);
|
||||
let dumped = false;
|
||||
@@ -294,8 +307,6 @@ function launchBundle(prepared: PreparedBundle): {
|
||||
console.error("[kandev] --- end backend stdout ---");
|
||||
};
|
||||
|
||||
attachBackendExitHandler(backendProc, supervisor);
|
||||
|
||||
const webServerPath = resolveWebServerPath(prepared.bundleDir);
|
||||
if (!webServerPath) {
|
||||
throw new Error("Web server entry (server.js) not found in bundle");
|
||||
@@ -319,7 +330,7 @@ export async function runRelease({
|
||||
verbose,
|
||||
debug,
|
||||
});
|
||||
const { supervisor, backendProc, webServerPath, dumpBackendLogs } = launchBundle(prepared);
|
||||
const { supervisor, backendProc, webServerPath, dumpBackendLogs } = await launchBundle(prepared);
|
||||
const healthTimeoutMs = resolveHealthTimeoutMs(HEALTH_TIMEOUT_MS_RELEASE);
|
||||
console.log("[kandev] starting backend...");
|
||||
await waitForHealth(prepared.backendUrl, backendProc, healthTimeoutMs, dumpBackendLogs);
|
||||
|
||||
@@ -244,9 +244,13 @@ export function networkUrlsForPort(port: number, hosts: string[]): string[] {
|
||||
export function attachBackendExitHandler(
|
||||
backendProc: ChildProcess,
|
||||
supervisor: ReturnType<typeof createProcessSupervisor>,
|
||||
options: { shouldShutdown?: () => boolean } = {},
|
||||
): void {
|
||||
backendProc.on("exit", (code, signal) => {
|
||||
console.error(`[kandev] backend exited (code=${code}, signal=${signal})`);
|
||||
if (options.shouldShutdown && !options.shouldShutdown()) {
|
||||
return;
|
||||
}
|
||||
const exitCode = signal ? 0 : (code ?? 1);
|
||||
void supervisor.shutdown("backend exit").then(() => process.exit(exitCode));
|
||||
});
|
||||
|
||||
+23
-14
@@ -11,21 +11,20 @@
|
||||
* - Or simply: `make build` (builds both)
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { HEALTH_TIMEOUT_MS_RELEASE, resolveDataDir, resolveDatabasePath } from "./constants";
|
||||
import {
|
||||
HEALTH_TIMEOUT_MS_RELEASE,
|
||||
resolveDataDir,
|
||||
resolveDatabasePath,
|
||||
resolveKandevHomeDir,
|
||||
} from "./constants";
|
||||
import { resolveHealthTimeoutMs, waitForHealth, waitForUrlReady } from "./health";
|
||||
import { getBinaryName } from "./platform";
|
||||
import { createProcessSupervisor } from "./process";
|
||||
import {
|
||||
attachBackendExitHandler,
|
||||
buildBackendEnv,
|
||||
buildWebEnv,
|
||||
logStartupInfo,
|
||||
pickPorts,
|
||||
} from "./shared";
|
||||
import { buildBackendEnv, buildWebEnv, logStartupInfo, pickPorts } from "./shared";
|
||||
import { launchRestartableBackend } from "./supervisor/backend";
|
||||
import { launchWebApp, openBrowser } from "./web";
|
||||
|
||||
/**
|
||||
@@ -82,6 +81,8 @@ export type StartOptions = {
|
||||
verbose?: boolean;
|
||||
/** Show debug logs + agent message dumps */
|
||||
debug?: boolean;
|
||||
/** Skip browser open. Set by service units and preview environments. */
|
||||
headless?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -103,6 +104,7 @@ export async function runStart({
|
||||
webPort,
|
||||
verbose = false,
|
||||
debug = false,
|
||||
headless = false,
|
||||
}: StartOptions): Promise<void> {
|
||||
const ports = await pickPorts(backendPort, webPort);
|
||||
|
||||
@@ -183,18 +185,21 @@ export async function runStart({
|
||||
|
||||
// Start backend: ignore stdin, show stdout only in verbose/debug mode, always show stderr
|
||||
// Stderr is always inherited to ensure error messages are visible immediately (no pipe buffering)
|
||||
const backendProc = spawn(backendBin, [], {
|
||||
const backend = await launchRestartableBackend({
|
||||
command: backendBin,
|
||||
args: [],
|
||||
cwd: path.dirname(backendBin),
|
||||
env: backendEnv,
|
||||
homeDir: resolveKandevHomeDir(),
|
||||
ports,
|
||||
mode: "start",
|
||||
stdio: showOutput ? ["ignore", "inherit", "inherit"] : ["ignore", "ignore", "inherit"],
|
||||
supervisor,
|
||||
});
|
||||
supervisor.children.push(backendProc);
|
||||
|
||||
attachBackendExitHandler(backendProc, supervisor);
|
||||
|
||||
const healthTimeoutMs = resolveHealthTimeoutMs(HEALTH_TIMEOUT_MS_RELEASE);
|
||||
console.log("[kandev] starting backend...");
|
||||
await waitForHealth(ports.backendUrl, backendProc, healthTimeoutMs);
|
||||
await waitForHealth(ports.backendUrl, backend.proc, healthTimeoutMs);
|
||||
console.log(`[kandev] backend ready at ${ports.backendUrl}`);
|
||||
|
||||
// Use standalone server.js directly (not pnpm start)
|
||||
@@ -212,5 +217,9 @@ export async function runStart({
|
||||
|
||||
await waitForUrlReady(webUrl, webProc, healthTimeoutMs);
|
||||
console.log("[kandev] open: " + ports.backendUrl);
|
||||
if (headless) {
|
||||
console.log(`[kandev] ready (headless) at ${ports.backendUrl}`);
|
||||
return;
|
||||
}
|
||||
openBrowser(ports.backendUrl);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { spawn, spawnSync, type ChildProcess, type StdioOptions } from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
import type { PortConfig } from "../shared";
|
||||
import { attachBackendExitHandler } from "../shared";
|
||||
import type { ChildLike, createProcessSupervisor } from "../process";
|
||||
import { buildLaunchManifest, writeLaunchManifest } from "./manifest";
|
||||
import { manifestPath, prepareSupervisorDir, socketPath } from "./paths";
|
||||
import { createRestartableChild } from "./child";
|
||||
import { startControlServer, type ControlServer } from "./control";
|
||||
|
||||
export type BackendSupervisorHandle = {
|
||||
proc: ChildProcess;
|
||||
control: ControlServer | null;
|
||||
env: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
export async function launchRestartableBackend({
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
homeDir,
|
||||
ports,
|
||||
mode,
|
||||
stdio,
|
||||
supervisor,
|
||||
}: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
homeDir: string;
|
||||
ports: PortConfig;
|
||||
mode: string;
|
||||
stdio: StdioOptions;
|
||||
supervisor: ReturnType<typeof createProcessSupervisor>;
|
||||
}): Promise<BackendSupervisorHandle> {
|
||||
if (!shouldUseSupervisor(env)) {
|
||||
const proc = spawn(command, args, { cwd, env, stdio });
|
||||
supervisor.children.push(proc as ChildLike);
|
||||
attachBackendExitHandler(proc, supervisor);
|
||||
return { proc, control: null, env };
|
||||
}
|
||||
|
||||
const supervisorEnv = withSupervisorEnv(env, homeDir);
|
||||
const manifest = buildLaunchManifest({
|
||||
backend_executable: resolveExecutable(command),
|
||||
argv: args,
|
||||
cwd,
|
||||
env: supervisorEnv,
|
||||
home_dir: homeDir,
|
||||
port: ports.backendPort,
|
||||
mode,
|
||||
});
|
||||
writeLaunchManifest(manifest, supervisorEnv.KANDEV_SUPERVISOR_MANIFEST);
|
||||
|
||||
const child = createRestartableChild(manifest, { stdio, extraEnv: supervisorEnv });
|
||||
let restarting = false;
|
||||
const attachExit = (proc: ChildProcess) => {
|
||||
attachBackendExitHandler(proc, supervisor, {
|
||||
shouldShutdown: () => !restarting,
|
||||
});
|
||||
};
|
||||
const proc = child.start();
|
||||
supervisor.children.push(proc as ChildLike);
|
||||
attachExit(proc);
|
||||
|
||||
const control = await startControlServer(supervisorEnv.KANDEV_SUPERVISOR_SOCKET, async () => {
|
||||
restarting = true;
|
||||
try {
|
||||
const next = await child.restart();
|
||||
supervisor.children.push(next as ChildLike);
|
||||
attachExit(next);
|
||||
} finally {
|
||||
restarting = false;
|
||||
}
|
||||
});
|
||||
|
||||
return { proc, control, env: supervisorEnv };
|
||||
}
|
||||
|
||||
export function shouldUseSupervisor(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return env.KANDEV_NO_SUPERVISOR !== "true";
|
||||
}
|
||||
|
||||
export function withSupervisorEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
homeDir: string,
|
||||
): NodeJS.ProcessEnv & {
|
||||
KANDEV_SUPERVISOR_SOCKET: string;
|
||||
KANDEV_SUPERVISOR_MANIFEST: string;
|
||||
KANDEV_RESTART_ADAPTER: string;
|
||||
} {
|
||||
prepareSupervisorDir(homeDir);
|
||||
return {
|
||||
...env,
|
||||
KANDEV_SUPERVISOR_SOCKET: socketPath(homeDir),
|
||||
KANDEV_SUPERVISOR_MANIFEST: manifestPath(homeDir),
|
||||
KANDEV_RESTART_ADAPTER: "supervisor",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveExecutable(command: string): string {
|
||||
if (path.isAbsolute(command)) return command;
|
||||
const found = spawnSync(process.platform === "win32" ? "where" : "which", [command], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const first = found.stdout
|
||||
?.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
if (!first) {
|
||||
throw new Error(`Unable to resolve executable ${command}`);
|
||||
}
|
||||
return first;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { spawn, type ChildProcess, type StdioOptions } from "node:child_process";
|
||||
import kill from "tree-kill";
|
||||
|
||||
import type { LaunchManifest } from "./manifest";
|
||||
|
||||
const RESTART_TIMEOUT_MS = 10000;
|
||||
|
||||
export type RestartableChild = {
|
||||
current: () => ChildProcess | null;
|
||||
start: () => ChildProcess;
|
||||
restart: () => Promise<ChildProcess>;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function createRestartableChild(
|
||||
manifest: LaunchManifest,
|
||||
options: { stdio?: StdioOptions; extraEnv?: NodeJS.ProcessEnv } = {},
|
||||
): RestartableChild {
|
||||
let child: ChildProcess | null = null;
|
||||
|
||||
const start = () => {
|
||||
child = spawn(manifest.backend_executable, manifest.argv, {
|
||||
cwd: manifest.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...manifest.env,
|
||||
...options.extraEnv,
|
||||
},
|
||||
stdio: options.stdio ?? "inherit",
|
||||
});
|
||||
return child;
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
if (!child?.pid || child.exitCode !== null) return;
|
||||
await terminate(child, RESTART_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const restart = async () => {
|
||||
await stop();
|
||||
return start();
|
||||
};
|
||||
|
||||
return {
|
||||
current: () => child,
|
||||
start,
|
||||
restart,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
||||
function terminate(proc: ChildProcess, timeoutMs: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const pid = proc.pid;
|
||||
if (!pid) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
kill(pid, process.platform === "win32" ? undefined : "SIGKILL", finish);
|
||||
}, timeoutMs);
|
||||
proc.once("exit", finish);
|
||||
kill(pid, process.platform === "win32" ? undefined : "SIGTERM", (err) => {
|
||||
if (err) finish();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { requestRestart, startControlServer } from "./control";
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tmpDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("supervisor control protocol", () => {
|
||||
it("accepts restart requests and calls the restart handler asynchronously", async () => {
|
||||
let calls = 0;
|
||||
const socket = testSocketPath();
|
||||
const server = await startControlServer(socket, async () => {
|
||||
calls += 1;
|
||||
});
|
||||
|
||||
const response = await requestRestart(socket);
|
||||
await waitFor(() => calls === 1);
|
||||
await server.close();
|
||||
|
||||
expect(response).toEqual({ accepted: true, message: "Restart accepted" });
|
||||
});
|
||||
|
||||
it("rejects concurrent restart requests while one is already running", async () => {
|
||||
let calls = 0;
|
||||
let releaseRestart!: () => void;
|
||||
let markRestartStarted!: () => void;
|
||||
const restartStarted = new Promise<void>((resolve) => {
|
||||
markRestartStarted = resolve;
|
||||
});
|
||||
const restartReleased = new Promise<void>((resolve) => {
|
||||
releaseRestart = resolve;
|
||||
});
|
||||
const socket = testSocketPath();
|
||||
const server = await startControlServer(socket, async () => {
|
||||
calls += 1;
|
||||
markRestartStarted();
|
||||
await restartReleased;
|
||||
});
|
||||
|
||||
const first = requestRestart(socket);
|
||||
await restartStarted;
|
||||
const second = await requestRestart(socket);
|
||||
releaseRestart();
|
||||
const firstResponse = await first;
|
||||
await server.close();
|
||||
|
||||
expect(firstResponse).toEqual({ accepted: true, message: "Restart accepted" });
|
||||
expect(second).toEqual({ accepted: false, message: "Restart already in progress" });
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
function testSocketPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-supervisor-control-"));
|
||||
tmpDirs.push(dir);
|
||||
if (process.platform === "win32") {
|
||||
return `\\\\.\\pipe\\kandev-test-${process.pid}-${Date.now()}`;
|
||||
}
|
||||
return path.join(dir, "control.sock");
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean): Promise<void> {
|
||||
const deadline = Date.now() + 1000;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error("condition not met");
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import net from "node:net";
|
||||
import fs from "node:fs";
|
||||
|
||||
export type ControlRequest = {
|
||||
action: "restart";
|
||||
};
|
||||
|
||||
export type ControlResponse = {
|
||||
accepted: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ControlServer = {
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function startControlServer(
|
||||
socket: string,
|
||||
onRestart: () => Promise<void>,
|
||||
): Promise<ControlServer> {
|
||||
let restartInProgress = false;
|
||||
const scheduleRestart = (): boolean => {
|
||||
if (restartInProgress) return false;
|
||||
restartInProgress = true;
|
||||
setTimeout(() => {
|
||||
void onRestart().finally(() => {
|
||||
restartInProgress = false;
|
||||
});
|
||||
}, 0);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (process.platform !== "win32" && fs.existsSync(socket)) {
|
||||
fs.unlinkSync(socket);
|
||||
}
|
||||
const server = net.createServer((conn) => {
|
||||
let buf = "";
|
||||
conn.setEncoding("utf8");
|
||||
conn.on("data", (chunk) => {
|
||||
buf += chunk;
|
||||
if (!buf.includes("\n")) return;
|
||||
const line = buf.slice(0, buf.indexOf("\n"));
|
||||
void handleControlLine(line, scheduleRestart)
|
||||
.then((resp) => {
|
||||
conn.end(`${JSON.stringify(resp)}\n`);
|
||||
})
|
||||
.catch((err) => {
|
||||
conn.end(
|
||||
`${JSON.stringify({
|
||||
accepted: false,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})}\n`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(socket, () => {
|
||||
server.off("error", reject);
|
||||
resolve({
|
||||
close: () =>
|
||||
new Promise<void>((closeResolve, closeReject) => {
|
||||
server.close((err) => {
|
||||
if (process.platform !== "win32" && fs.existsSync(socket)) {
|
||||
fs.unlinkSync(socket);
|
||||
}
|
||||
if (err) closeReject(err);
|
||||
else closeResolve();
|
||||
});
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function requestRestart(socket: string): Promise<ControlResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = net.createConnection(socket);
|
||||
let buf = "";
|
||||
conn.setEncoding("utf8");
|
||||
conn.on("connect", () => {
|
||||
conn.write(`${JSON.stringify({ action: "restart" satisfies ControlRequest["action"] })}\n`);
|
||||
});
|
||||
conn.on("data", (chunk) => {
|
||||
buf += chunk;
|
||||
});
|
||||
conn.on("error", reject);
|
||||
conn.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(buf.trim()) as ControlResponse);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleControlLine(
|
||||
line: string,
|
||||
scheduleRestart: () => boolean,
|
||||
): Promise<ControlResponse> {
|
||||
const req = JSON.parse(line) as ControlRequest;
|
||||
if (req.action !== "restart") {
|
||||
return { accepted: false, message: `unsupported action ${String(req.action)}` };
|
||||
}
|
||||
if (!scheduleRestart()) {
|
||||
return { accepted: false, message: "Restart already in progress" };
|
||||
}
|
||||
return { accepted: true, message: "Restart accepted" };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { manifestPath, prepareSupervisorDir } from "./paths";
|
||||
|
||||
export type LaunchManifest = {
|
||||
version: 1;
|
||||
backend_executable: string;
|
||||
argv: string[];
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
home_dir: string;
|
||||
port: number;
|
||||
mode: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type LaunchManifestInput = Omit<LaunchManifest, "version" | "created_at" | "env"> & {
|
||||
env: NodeJS.ProcessEnv;
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
const ENV_ALLOWLIST = new Set([
|
||||
"KANDEV_HOME_DIR",
|
||||
"KANDEV_DATABASE_PATH",
|
||||
"KANDEV_SERVER_PORT",
|
||||
"KANDEV_WEB_INTERNAL_URL",
|
||||
"KANDEV_AGENT_STANDALONE_PORT",
|
||||
"KANDEV_LOG_LEVEL",
|
||||
"KANDEV_DEBUG_DEV_MODE",
|
||||
"KANDEV_DEBUG_AGENT_MESSAGES",
|
||||
"KANDEV_DEBUG_PPROF_ENABLED",
|
||||
"KANDEV_E2E_MOCK",
|
||||
"KANDEV_MOCK_AGENT",
|
||||
"KANDEV_MOCK_GITHUB",
|
||||
"KANDEV_MOCK_JIRA",
|
||||
"KANDEV_MOCK_LINEAR",
|
||||
"KANDEV_SUPERVISOR_SOCKET",
|
||||
"KANDEV_SUPERVISOR_MANIFEST",
|
||||
"KANDEV_RESTART_ADAPTER",
|
||||
]);
|
||||
|
||||
export function buildLaunchManifest(input: LaunchManifestInput): LaunchManifest {
|
||||
if (!path.isAbsolute(input.backend_executable)) {
|
||||
throw new Error("backend_executable must be absolute");
|
||||
}
|
||||
if (!path.isAbsolute(input.cwd)) {
|
||||
throw new Error("cwd must be absolute");
|
||||
}
|
||||
if (!path.isAbsolute(input.home_dir)) {
|
||||
throw new Error("home_dir must be absolute");
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
backend_executable: input.backend_executable,
|
||||
argv: [...input.argv],
|
||||
cwd: input.cwd,
|
||||
env: allowedEnv(input.env),
|
||||
home_dir: input.home_dir,
|
||||
port: input.port,
|
||||
mode: input.mode,
|
||||
created_at: (input.now ?? new Date()).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function writeLaunchManifest(
|
||||
manifest: LaunchManifest,
|
||||
targetPath = manifestPath(manifest.home_dir),
|
||||
): string {
|
||||
prepareSupervisorDir(manifest.home_dir);
|
||||
fs.writeFileSync(targetPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
||||
if (process.platform !== "win32") {
|
||||
fs.chmodSync(targetPath, 0o600);
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function readLaunchManifest(targetPath: string): LaunchManifest {
|
||||
return JSON.parse(fs.readFileSync(targetPath, "utf8")) as LaunchManifest;
|
||||
}
|
||||
|
||||
export function allowedEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const key of ENV_ALLOWLIST) {
|
||||
const value = env[key];
|
||||
if (value !== undefined) {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/config", () => ({
|
||||
getBackendConfig: () => ({ apiBaseUrl: "http://backend.test" }),
|
||||
}));
|
||||
|
||||
import { getRuntimeDebugModeAction } from "./features";
|
||||
|
||||
type FetchInput = Parameters<typeof fetch>[0];
|
||||
type FetchInit = Parameters<typeof fetch>[1];
|
||||
|
||||
const fetchSpy = vi.fn<[FetchInput, FetchInit?], Promise<Response>>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy.mockReset();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("getRuntimeDebugModeAction", () => {
|
||||
it("returns true when the effective debug runtime flag is enabled", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
flags: [{ key: "debug.devMode", effective_value: true }],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(getRuntimeDebugModeAction()).resolves.toBe(true);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith("http://backend.test/api/v1/runtime-flags", {
|
||||
cache: "no-store",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to false when runtime flags cannot be loaded", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response("nope", { status: 503 }));
|
||||
|
||||
await expect(getRuntimeDebugModeAction()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the debug runtime flag is absent", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
flags: [{ key: "features.office", effective_value: true }],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(getRuntimeDebugModeAction()).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import { getBackendConfig } from "@/lib/config";
|
||||
import { defaultFeaturesState } from "@/lib/state/slices/features/features-slice";
|
||||
import type { FeatureFlags } from "@/lib/state/slices/features/types";
|
||||
import type { RuntimeFlagsResponse } from "@/lib/types/runtime-flags";
|
||||
|
||||
const { apiBaseUrl } = getBackendConfig();
|
||||
|
||||
@@ -32,6 +33,20 @@ export async function getFeatureFlagsAction(): Promise<FeatureFlags> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRuntimeDebugModeAction(): Promise<boolean> {
|
||||
const url = `${apiBaseUrl}/api/v1/runtime-flags`;
|
||||
try {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
const data = (await response.json()) as RuntimeFlagsResponse;
|
||||
return data.flags.some((flag) => flag.key === "debug.devMode" && flag.effective_value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFlags(data: Record<string, unknown>, defaults: FeatureFlags): FeatureFlags {
|
||||
const out = { ...defaults };
|
||||
for (const key of Object.keys(out) as (keyof FeatureFlags)[]) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { ConfigChatProvider } from "@/components/config-chat/config-chat-provide
|
||||
import { SessionFailureToastBridge } from "@/components/session-failure-toast-bridge";
|
||||
import { SidebarViewsSyncBridge } from "@/components/sidebar-views-sync-bridge";
|
||||
import { LogBufferBridge } from "@/components/log-buffer-bridge";
|
||||
import { getFeatureFlagsAction } from "@/app/actions/features";
|
||||
import { getFeatureFlagsAction, getRuntimeDebugModeAction } from "@/app/actions/features";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Kandev - AI Kanban",
|
||||
@@ -41,14 +41,18 @@ export default async function RootLayout({
|
||||
// In production single-port mode, this is not needed — the client uses
|
||||
// window.location.origin (same-origin, works for any domain / reverse proxy).
|
||||
const apiPort = process.env.NEXT_PUBLIC_KANDEV_API_PORT ?? null;
|
||||
const debugMode =
|
||||
const envDebugMode =
|
||||
process.env.KANDEV_DEBUG === "true" || process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true";
|
||||
|
||||
// SSR-fetch the deployment's feature flags so the entire client tree
|
||||
// (including the sidebar nav and gated routes) renders with the correct
|
||||
// visibility on the first paint. Falls back to all-off when the backend
|
||||
// is unreachable. See docs/decisions/0007-runtime-feature-flags.md.
|
||||
const features = await getFeatureFlagsAction();
|
||||
const [features, runtimeDebugMode] = await Promise.all([
|
||||
getFeatureFlagsAction(),
|
||||
getRuntimeDebugModeAction(),
|
||||
]);
|
||||
const debugMode = envDebugMode || runtimeDebugMode;
|
||||
|
||||
const runtimeConfigScript =
|
||||
apiPort || debugMode
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { FeatureTogglesSettings } from "@/components/settings/system/feature-toggles-settings";
|
||||
import { SystemPageShell } from "@/components/settings/system/system-page-shell";
|
||||
import { fetchRuntimeFlags } from "@/lib/api/domains/runtime-flags-api";
|
||||
import { fetchRestartCapability } from "@/lib/api/domains/system-api";
|
||||
|
||||
export default async function FeatureTogglesPage() {
|
||||
const [flagsResponse, restartCapability] = await Promise.all([
|
||||
fetchRuntimeFlags({ cache: "no-store" }).catch(() => null),
|
||||
fetchRestartCapability({ cache: "no-store" }).catch(() => null),
|
||||
]);
|
||||
|
||||
return (
|
||||
<SystemPageShell
|
||||
title="Feature Toggles"
|
||||
description="Enable or disable experimental and diagnostic Kandev features."
|
||||
>
|
||||
<FeatureTogglesSettings
|
||||
initialFlags={flagsResponse?.flags ?? []}
|
||||
restartCapability={restartCapability}
|
||||
/>
|
||||
</SystemPageShell>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ describe("settingsGroupIdForPath", () => {
|
||||
expect(settingsGroupIdForPath("/settings/executors/profile-123")).toBe("executors");
|
||||
expect(settingsGroupIdForPath("/settings/workspace/ws-1/repositories")).toBe("workspaces");
|
||||
expect(settingsGroupIdForPath("/settings/system/logs")).toBe("system");
|
||||
expect(settingsGroupIdForPath("/settings/system/feature-toggles")).toBe("system");
|
||||
// Editors/Secrets live under /settings/general so they belong to General.
|
||||
expect(settingsGroupIdForPath("/settings/general/editors")).toBe("general");
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IconArchive,
|
||||
IconDatabase,
|
||||
IconFileText,
|
||||
IconFlask,
|
||||
IconInfoCircle,
|
||||
IconRefresh,
|
||||
IconScale,
|
||||
@@ -18,6 +19,7 @@ const DEFAULT_HREF = `${ROOT_HREF}/status`;
|
||||
|
||||
const ITEMS: Array<{ href: string; label: string; icon: TablerIcon }> = [
|
||||
{ href: `${ROOT_HREF}/status`, label: "Status", icon: IconActivity },
|
||||
{ href: `${ROOT_HREF}/feature-toggles`, label: "Feature Toggles", icon: IconFlask },
|
||||
{ href: `${ROOT_HREF}/database`, label: "Database", icon: IconDatabase },
|
||||
{ href: `${ROOT_HREF}/backups`, label: "Backups", icon: IconArchive },
|
||||
{ href: `${ROOT_HREF}/logs`, label: "Logs", icon: IconFileText },
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeFlagState } from "@/lib/types/runtime-flags";
|
||||
import { FeatureToggleCard } from "./feature-toggle-card";
|
||||
|
||||
vi.mock("@kandev/ui/switch", () => ({
|
||||
Switch: ({
|
||||
checked,
|
||||
disabled,
|
||||
"aria-label": ariaLabel,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
"aria-label": string;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
}) => (
|
||||
<button
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={checked}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("FeatureToggleCard", () => {
|
||||
it("shows risk copy as supporting text instead of a warning alert", () => {
|
||||
render(
|
||||
<FeatureToggleCard
|
||||
flag={flagState({
|
||||
risk_description:
|
||||
"Office mode is still evolving and should be reviewed before relying on it.",
|
||||
})}
|
||||
saving={false}
|
||||
onChange={() => undefined}
|
||||
onReset={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Office mode is still evolving/)).not.toBeNull();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onChange with the next switch value", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<FeatureToggleCard
|
||||
flag={flagState({ effective_value: false })}
|
||||
saving={false}
|
||||
onChange={onChange}
|
||||
onReset={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Toggle Office mode"));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("calls onReset from the use default action when an override exists", () => {
|
||||
const onReset = vi.fn();
|
||||
render(
|
||||
<FeatureToggleCard
|
||||
flag={flagState({ override_value: false })}
|
||||
saving={false}
|
||||
onChange={() => undefined}
|
||||
onReset={onReset}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /use default/i }));
|
||||
|
||||
expect(onReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables changes and reset when the launch environment controls the flag", () => {
|
||||
const onChange = vi.fn();
|
||||
const onReset = vi.fn();
|
||||
render(
|
||||
<FeatureToggleCard
|
||||
flag={flagState({ env_locked: true, source: "env" })}
|
||||
saving={false}
|
||||
onChange={onChange}
|
||||
onReset={onReset}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Toggle Office mode")).toHaveProperty("disabled", true);
|
||||
expect(screen.getByRole("button", { name: /use default/i })).toHaveProperty("disabled", true);
|
||||
expect(screen.getByText("Controlled by launch environment")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("disables changes and reset for immutable flags", () => {
|
||||
const onChange = vi.fn();
|
||||
const onReset = vi.fn();
|
||||
render(
|
||||
<FeatureToggleCard
|
||||
flag={flagState({ mutable: false, override_value: false })}
|
||||
saving={false}
|
||||
onChange={onChange}
|
||||
onReset={onReset}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Toggle Office mode")).toHaveProperty("disabled", true);
|
||||
expect(screen.getByRole("button", { name: /use default/i })).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
it("shows restart metadata when a saved change is pending", () => {
|
||||
render(
|
||||
<FeatureToggleCard
|
||||
flag={flagState({ restart_required: true, requires_restart_to_apply: true })}
|
||||
saving={false}
|
||||
onChange={() => undefined}
|
||||
onReset={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Requires restart")).not.toBeNull();
|
||||
expect(screen.getByText("Pending restart")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function flagState(overrides: Partial<RuntimeFlagState> = {}): RuntimeFlagState {
|
||||
return {
|
||||
key: "features.office",
|
||||
env_var: "KANDEV_FEATURES_OFFICE",
|
||||
label: "Office mode",
|
||||
description: "Enables autonomous agent office workflows and related settings.",
|
||||
kind: "feature",
|
||||
stability: "experimental",
|
||||
risk_level: "medium",
|
||||
risk_description: "",
|
||||
default_value: false,
|
||||
override_value: true,
|
||||
effective_value: true,
|
||||
source: "override",
|
||||
env_locked: false,
|
||||
restart_required: true,
|
||||
requires_restart_to_apply: true,
|
||||
mutable: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Switch } from "@kandev/ui/switch";
|
||||
import { IconFlask, IconLock, IconRefresh } from "@tabler/icons-react";
|
||||
import type { RuntimeFlagState } from "@/lib/types/runtime-flags";
|
||||
|
||||
type FeatureToggleCardProps = {
|
||||
flag: RuntimeFlagState;
|
||||
saving: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
export function FeatureToggleCard({ flag, saving, onChange, onReset }: FeatureToggleCardProps) {
|
||||
const disabled = saving || flag.env_locked || !flag.mutable;
|
||||
return (
|
||||
<Card data-testid={`feature-toggle-${flag.key}`}>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
|
||||
{flag.label}
|
||||
<FlagBadges flag={flag} />
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{flag.description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={flag.effective_value}
|
||||
disabled={disabled}
|
||||
onCheckedChange={onChange}
|
||||
aria-label={`Toggle ${flag.label}`}
|
||||
className="cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{flag.risk_description && (
|
||||
<p className="text-sm leading-6 text-muted-foreground">{flag.risk_description}</p>
|
||||
)}
|
||||
<FlagMetadata flag={flag} />
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={saving || flag.env_locked || !flag.mutable || flag.override_value == null}
|
||||
onClick={onReset}
|
||||
className="cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<IconRefresh className="mr-1 h-3.5 w-3.5" />
|
||||
Use default
|
||||
</Button>
|
||||
{flag.env_locked && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<IconLock className="h-3.5 w-3.5" />
|
||||
Controlled by launch environment
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FlagBadges({ flag }: { flag: RuntimeFlagState }) {
|
||||
return (
|
||||
<>
|
||||
{flag.stability === "experimental" && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<IconFlask className="h-3 w-3" />
|
||||
Experimental
|
||||
</Badge>
|
||||
)}
|
||||
{flag.kind === "debug" && <Badge variant="outline">Debug</Badge>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FlagMetadata({ flag }: { flag: RuntimeFlagState }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 text-xs text-muted-foreground sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<span>Source: {sourceLabel(flag)}</span>
|
||||
<span>Env: {flag.env_var}</span>
|
||||
{flag.restart_required && <span>Requires restart</span>}
|
||||
{flag.requires_restart_to_apply && (
|
||||
<span className="font-medium text-amber-700">Pending restart</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceLabel(flag: RuntimeFlagState): string {
|
||||
if (flag.source === "env") return "Environment";
|
||||
if (flag.source === "override") return "Saved override";
|
||||
return "Default";
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeFlagState } from "@/lib/types/runtime-flags";
|
||||
import { FeatureTogglesSettings } from "./feature-toggles-settings";
|
||||
|
||||
vi.mock("@kandev/ui/switch", () => ({
|
||||
Switch: ({
|
||||
checked,
|
||||
disabled,
|
||||
"aria-label": ariaLabel,
|
||||
}: {
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
"aria-label": string;
|
||||
}) => <button aria-label={ariaLabel} aria-pressed={checked} disabled={disabled} type="button" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/toast-provider", () => ({
|
||||
useToast: () => ({ toast: vi.fn() }),
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("FeatureTogglesSettings", () => {
|
||||
it("shows restart support details without offering restart when unsupported", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<FeatureTogglesSettings
|
||||
initialFlags={[flagState()]}
|
||||
restartCapability={{
|
||||
supported: false,
|
||||
mode: "manual",
|
||||
reason: "Automatic restart is not available for this launch mode.",
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Restart required")).not.toBeNull();
|
||||
expect(screen.getByLabelText("Restart support details")).not.toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
|
||||
expect(screen.getByText(/terminal or service manager/)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function flagState(overrides: Partial<RuntimeFlagState> = {}): RuntimeFlagState {
|
||||
return {
|
||||
key: "debug.devMode",
|
||||
env_var: "KANDEV_DEBUG_DEV_MODE",
|
||||
label: "Debug mode",
|
||||
description: "Enables diagnostic tools for troubleshooting.",
|
||||
kind: "debug",
|
||||
stability: "stable",
|
||||
risk_level: "high",
|
||||
risk_description: "Use only on trusted machines.",
|
||||
default_value: false,
|
||||
override_value: true,
|
||||
effective_value: true,
|
||||
source: "override",
|
||||
env_locked: false,
|
||||
restart_required: true,
|
||||
requires_restart_to_apply: true,
|
||||
mutable: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Card, CardContent } from "@kandev/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { IconInfoCircle, IconPower, IconRotateClockwise } from "@tabler/icons-react";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useKandevRestart } from "@/hooks/domains/system/use-kandev-restart";
|
||||
import { fetchRuntimeFlags, updateRuntimeFlag } from "@/lib/api/domains/runtime-flags-api";
|
||||
import type { RuntimeFlagState } from "@/lib/types/runtime-flags";
|
||||
import type { RestartCapability } from "@/lib/types/system";
|
||||
import { FeatureToggleCard } from "./feature-toggle-card";
|
||||
import { RestartProgressDialog } from "./restart-progress-dialog";
|
||||
|
||||
type Props = {
|
||||
initialFlags: RuntimeFlagState[];
|
||||
restartCapability: RestartCapability | null;
|
||||
};
|
||||
|
||||
export function FeatureTogglesSettings({ initialFlags, restartCapability }: Props) {
|
||||
const [flags, setFlags] = useState(initialFlags);
|
||||
const [savingKeys, setSavingKeys] = useState<Set<string>>(() => new Set());
|
||||
const requestSeqRef = useRef(0);
|
||||
const { toast } = useToast();
|
||||
const pendingRestart = useMemo(
|
||||
() => flags.some((flag) => flag.requires_restart_to_apply),
|
||||
[flags],
|
||||
);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const seq = nextRequestSeq(requestSeqRef);
|
||||
try {
|
||||
const res = await fetchRuntimeFlags();
|
||||
setFlagsIfLatest(requestSeqRef, seq, res.flags, setFlags);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to load feature toggles",
|
||||
description: errorMessage(err),
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
const onRestartComplete = useCallback(() => void reload(), [reload]);
|
||||
const restart = useKandevRestart({ onComplete: onRestartComplete });
|
||||
|
||||
const setOverride = async (flag: RuntimeFlagState, override: boolean | null) => {
|
||||
const seq = nextRequestSeq(requestSeqRef);
|
||||
setSavingKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(flag.key);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const res = await updateRuntimeFlag(flag.key, override);
|
||||
setFlagsIfLatest(requestSeqRef, seq, res.flags, setFlags);
|
||||
toast({ title: "Feature toggle saved", variant: "success" });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to save feature toggle",
|
||||
description: errorMessage(err),
|
||||
variant: "error",
|
||||
});
|
||||
} finally {
|
||||
setSavingKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(flag.key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="feature-toggles-settings">
|
||||
{pendingRestart && (
|
||||
<RestartRequiredAlert
|
||||
capability={restartCapability}
|
||||
restarting={restart.isRestarting}
|
||||
onRestart={() => void restart.start()}
|
||||
/>
|
||||
)}
|
||||
{flags.map((flag) => (
|
||||
<FeatureToggleCard
|
||||
key={flag.key}
|
||||
flag={flag}
|
||||
saving={savingKeys.has(flag.key) || restart.isRestarting}
|
||||
onChange={(next) => void setOverride(flag, next)}
|
||||
onReset={() => void setOverride(flag, null)}
|
||||
/>
|
||||
))}
|
||||
{flags.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-6 text-sm text-muted-foreground">
|
||||
Feature toggles could not be loaded.
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto px-1 cursor-pointer"
|
||||
onClick={() => void reload()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<RestartProgressDialog
|
||||
phase={restart.phase}
|
||||
errorMessage={restart.errorMessage}
|
||||
onDismiss={restart.dismiss}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RestartRequiredAlert({
|
||||
capability,
|
||||
restarting,
|
||||
onRestart,
|
||||
}: {
|
||||
capability: RestartCapability | null;
|
||||
restarting: boolean;
|
||||
onRestart: () => void;
|
||||
}) {
|
||||
const supported = capability?.supported === true;
|
||||
return (
|
||||
<Alert className="border-border/70 bg-muted/30">
|
||||
<IconRotateClockwise className="h-4 w-4 text-muted-foreground" />
|
||||
<AlertTitle className="flex items-center gap-2">
|
||||
Restart required
|
||||
<RestartSupportInfo supported={supported} reason={capability?.reason} />
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-3 text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>
|
||||
Saved toggle changes will apply the next time Kandev starts.
|
||||
{!supported && " Restart it from your terminal or service manager when convenient."}
|
||||
</span>
|
||||
{supported && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onRestart}
|
||||
disabled={restarting}
|
||||
className="w-full cursor-pointer sm:w-auto"
|
||||
>
|
||||
<IconPower className="mr-1 h-3.5 w-3.5" />
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function RestartSupportInfo({
|
||||
supported,
|
||||
reason,
|
||||
}: {
|
||||
supported: boolean;
|
||||
reason: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Restart support details"
|
||||
className="inline-flex h-6 w-6 cursor-help items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<IconInfoCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs text-xs leading-relaxed">
|
||||
{restartSupportMessage(supported, reason)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function restartSupportMessage(supported: boolean, reason: string | undefined): string {
|
||||
if (supported) {
|
||||
return "Restart from this page is available when Kandev is running under a supported local supervisor.";
|
||||
}
|
||||
return (
|
||||
reason ??
|
||||
"Automatic restart is not available in deploy previews, unmanaged terminal runs, or launch modes without a restart supervisor."
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
function nextRequestSeq(seqRef: MutableRefObject<number>): number {
|
||||
seqRef.current += 1;
|
||||
return seqRef.current;
|
||||
}
|
||||
|
||||
function setFlagsIfLatest(
|
||||
seqRef: MutableRefObject<number>,
|
||||
seq: number,
|
||||
flags: RuntimeFlagState[],
|
||||
setFlags: Dispatch<SetStateAction<RuntimeFlagState[]>>,
|
||||
) {
|
||||
if (seq === seqRef.current) {
|
||||
setFlags(flags);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@kandev/ui/dialog";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { IconAlertTriangle, IconCheck } from "@tabler/icons-react";
|
||||
import type { KandevRestartPhase } from "@/hooks/domains/system/use-kandev-restart";
|
||||
|
||||
type RestartProgressDialogProps = {
|
||||
phase: KandevRestartPhase;
|
||||
errorMessage: string | null;
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
export function RestartProgressDialog({
|
||||
phase,
|
||||
errorMessage,
|
||||
onDismiss,
|
||||
}: RestartProgressDialogProps) {
|
||||
if (phase === "idle") return null;
|
||||
const done = phase === "done";
|
||||
const failed = phase === "error";
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && (done || failed) && onDismiss()}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
data-testid="restart-progress-dialog"
|
||||
data-phase={phase}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<RestartStatusIcon phase={phase} />
|
||||
{restartTitle(phase)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{restartDescription(phase, errorMessage)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{(done || failed) && (
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant={failed ? "outline" : "default"}
|
||||
className="w-full cursor-pointer sm:w-auto"
|
||||
onClick={done ? () => window.location.reload() : onDismiss}
|
||||
>
|
||||
{done ? "Reload page" : "Dismiss"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RestartStatusIcon({ phase }: { phase: KandevRestartPhase }) {
|
||||
if (phase === "done") return <IconCheck className="size-4 text-emerald-500" />;
|
||||
if (phase === "error") return <IconAlertTriangle className="size-4 text-destructive" />;
|
||||
return <Spinner className="size-4" />;
|
||||
}
|
||||
|
||||
function restartTitle(phase: KandevRestartPhase): string {
|
||||
switch (phase) {
|
||||
case "starting":
|
||||
return "Requesting restart";
|
||||
case "restarting":
|
||||
return "Restarting Kandev";
|
||||
case "done":
|
||||
return "Kandev restarted";
|
||||
case "error":
|
||||
return "Restart failed";
|
||||
default:
|
||||
return "Restarting Kandev";
|
||||
}
|
||||
}
|
||||
|
||||
function restartDescription(phase: KandevRestartPhase, errorMessage: string | null): string {
|
||||
switch (phase) {
|
||||
case "starting":
|
||||
return "Preparing the local supervisor restart request.";
|
||||
case "restarting":
|
||||
return "Kandev is stopping and starting again. This page will detect the new process automatically.";
|
||||
case "done":
|
||||
return "The backend is running again. Reload the page to reconnect with the latest feature toggle state.";
|
||||
case "error":
|
||||
return errorMessage ?? "The restart could not be completed.";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const DIFFS_CONTAINER = "diffs-container";
|
||||
|
||||
type SeedTaskOptions = {
|
||||
title: string;
|
||||
scenarioCommand: string;
|
||||
completionText: string;
|
||||
};
|
||||
|
||||
async function seedTaskWithScenario(
|
||||
testPage: Page,
|
||||
apiClient: ApiClient,
|
||||
seedData: SeedData,
|
||||
options: SeedTaskOptions,
|
||||
): Promise<{ session: SessionPage; sessionId: string }> {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
options.title,
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: options.scenarioCommand,
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await closePreviewPanels(testPage);
|
||||
|
||||
await expect(session.chat.getByText(options.completionText, { exact: false })).toBeVisible({
|
||||
timeout: 45_000,
|
||||
});
|
||||
|
||||
return { session, sessionId: task.session_id };
|
||||
}
|
||||
|
||||
export async function closePreviewPanels(testPage: Page) {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
testPage.evaluate(() => {
|
||||
type TestWindow = Window & {
|
||||
__dockviewApi__?: {
|
||||
getPanel: (id: string) => unknown;
|
||||
removePanel: (panel: unknown) => void;
|
||||
};
|
||||
};
|
||||
const dockview = (window as TestWindow).__dockviewApi__;
|
||||
if (!dockview) return false;
|
||||
for (const id of ["preview:file-diff", "preview:file-editor"]) {
|
||||
const panel = dockview.getPanel(id);
|
||||
if (panel) dockview.removePanel(panel);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export function seedUntrackedFileTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) {
|
||||
return seedTaskWithScenario(testPage, apiClient, seedData, {
|
||||
title: "Untracked File E2E",
|
||||
scenarioCommand: "/e2e:untracked-file-setup",
|
||||
completionText: "untracked-file-setup complete",
|
||||
});
|
||||
}
|
||||
|
||||
export function seedDiffUpdateTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) {
|
||||
return seedTaskWithScenario(testPage, apiClient, seedData, {
|
||||
title: "Diff Update E2E",
|
||||
scenarioCommand: "/e2e:diff-update-setup",
|
||||
completionText: "diff-update-setup complete",
|
||||
});
|
||||
}
|
||||
|
||||
export function seedMultiFileTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) {
|
||||
return seedTaskWithScenario(testPage, apiClient, seedData, {
|
||||
title: "Multi-file Diff Update E2E",
|
||||
scenarioCommand: "/e2e:multi-file-setup",
|
||||
completionText: "multi-file-setup complete",
|
||||
});
|
||||
}
|
||||
|
||||
export async function openChangesTab(testPage: Page) {
|
||||
const changesTab = testPage.locator(".dv-default-tab", { hasText: "Changes" });
|
||||
await expect(changesTab).toBeVisible({ timeout: 10_000 });
|
||||
await changesTab.click();
|
||||
}
|
||||
|
||||
export async function openFileDiff(testPage: Page, fileName: string) {
|
||||
const fileRow = testPage.getByTestId(`file-row-${fileName.replace(/[/\\]/g, "-")}`);
|
||||
await expect(fileRow).toBeVisible({ timeout: 10_000 });
|
||||
await fileRow.click();
|
||||
}
|
||||
|
||||
export function fileDiffTab(testPage: Page, fileName: string) {
|
||||
return testPage.locator(".dv-default-tab[type='file-diff']", {
|
||||
hasText: `Diff [${fileName}]`,
|
||||
});
|
||||
}
|
||||
|
||||
export function getDiffsContainer(testPage: Page) {
|
||||
return testPage.locator(DIFFS_CONTAINER);
|
||||
}
|
||||
|
||||
export async function waitForDiffText(testPage: Page, text: string, timeout = 60_000) {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
testPage.evaluate((expected) => {
|
||||
const containers = Array.from(document.querySelectorAll("diffs-container")).filter(
|
||||
(el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(el);
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden"
|
||||
);
|
||||
},
|
||||
);
|
||||
return containers.some((container) =>
|
||||
container.shadowRoot?.textContent?.includes(expected),
|
||||
);
|
||||
}, text),
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function waitForDiffTextAbsent(testPage: Page, text: string, timeout = 5_000) {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
testPage.evaluate((expected) => {
|
||||
const containers = Array.from(document.querySelectorAll("diffs-container")).filter(
|
||||
(el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(el);
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden"
|
||||
);
|
||||
},
|
||||
);
|
||||
return containers.every(
|
||||
(container) => !container.shadowRoot?.textContent?.includes(expected),
|
||||
);
|
||||
}, text),
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function waitForStoreFileDiffText(
|
||||
testPage: Page,
|
||||
sessionId: string,
|
||||
filePath: string,
|
||||
text: string,
|
||||
timeout = 30_000,
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
testPage.evaluate(
|
||||
({ sid, path, expected }) => {
|
||||
type E2EStoreWindow = Window & {
|
||||
__KANDEV_E2E_STORE__?: {
|
||||
getState: () => {
|
||||
environmentIdBySessionId: Record<string, string>;
|
||||
gitStatus: {
|
||||
byEnvironmentId: Record<
|
||||
string,
|
||||
{ files?: Record<string, { diff?: string }> } | undefined
|
||||
>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__;
|
||||
const state = store?.getState();
|
||||
if (!state) return false;
|
||||
const envKey = state.environmentIdBySessionId[sid] ?? sid;
|
||||
return state.gitStatus.byEnvironmentId[envKey]?.files?.[path]?.diff?.includes(expected);
|
||||
},
|
||||
{ sid: sessionId, path: filePath, expected: text },
|
||||
),
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
@@ -1,104 +1,16 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
// Shared selector constant for diff container
|
||||
const DIFFS_CONTAINER = "diffs-container";
|
||||
|
||||
/** Options for seeding a task with an agent. */
|
||||
type SeedTaskOptions = {
|
||||
title: string;
|
||||
scenarioCommand: string;
|
||||
completionText: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic helper to seed a task using a mock scenario and navigate to
|
||||
* its session page, waiting for the agent turn to complete.
|
||||
*/
|
||||
async function seedTaskWithScenario(
|
||||
testPage: Page,
|
||||
apiClient: ApiClient,
|
||||
seedData: SeedData,
|
||||
options: SeedTaskOptions,
|
||||
): Promise<{ session: SessionPage; sessionId: string }> {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
options.title,
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: options.scenarioCommand,
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
// Wait for the first turn to complete
|
||||
await expect(session.chat.getByText(options.completionText, { exact: false })).toBeVisible({
|
||||
timeout: 45_000,
|
||||
});
|
||||
|
||||
return { session, sessionId: task.session_id };
|
||||
}
|
||||
|
||||
/** Seed a task for untracked file testing. */
|
||||
function seedUntrackedFileTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) {
|
||||
return seedTaskWithScenario(testPage, apiClient, seedData, {
|
||||
title: "Untracked File E2E",
|
||||
scenarioCommand: "/e2e:untracked-file-setup",
|
||||
completionText: "untracked-file-setup complete",
|
||||
});
|
||||
}
|
||||
|
||||
/** Seed a task for diff update testing. */
|
||||
function seedDiffUpdateTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) {
|
||||
return seedTaskWithScenario(testPage, apiClient, seedData, {
|
||||
title: "Diff Update E2E",
|
||||
scenarioCommand: "/e2e:diff-update-setup",
|
||||
completionText: "diff-update-setup complete",
|
||||
});
|
||||
}
|
||||
|
||||
/** Seed a task for the multi-file scenario (3 files, FIRST_MODIFICATION on all). */
|
||||
function seedMultiFileTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) {
|
||||
return seedTaskWithScenario(testPage, apiClient, seedData, {
|
||||
title: "Multi-file Diff Update E2E",
|
||||
scenarioCommand: "/e2e:multi-file-setup",
|
||||
completionText: "multi-file-setup complete",
|
||||
});
|
||||
}
|
||||
|
||||
/** Click the Changes dockview tab. */
|
||||
async function openChangesTab(testPage: Page) {
|
||||
const changesTab = testPage.locator(".dv-default-tab", { hasText: "Changes" });
|
||||
await expect(changesTab).toBeVisible({ timeout: 10_000 });
|
||||
await changesTab.click();
|
||||
}
|
||||
|
||||
/** Click a file row by name to open its diff view. */
|
||||
async function openFileDiff(testPage: Page, fileName: string) {
|
||||
const fileRow = testPage
|
||||
.locator("button, [role='button'], [class*='file']")
|
||||
.filter({ hasText: fileName })
|
||||
.first();
|
||||
await expect(fileRow).toBeVisible({ timeout: 10_000 });
|
||||
await fileRow.click();
|
||||
}
|
||||
|
||||
/** Helper to get the diffs container locator. */
|
||||
function getDiffsContainer(testPage: Page) {
|
||||
return testPage.locator(DIFFS_CONTAINER);
|
||||
}
|
||||
import {
|
||||
fileDiffTab,
|
||||
getDiffsContainer,
|
||||
openChangesTab,
|
||||
openFileDiff,
|
||||
seedDiffUpdateTask,
|
||||
seedMultiFileTask,
|
||||
seedUntrackedFileTask,
|
||||
waitForDiffText,
|
||||
waitForDiffTextAbsent,
|
||||
waitForStoreFileDiffText,
|
||||
} from "./diff-update-helpers";
|
||||
|
||||
test.describe("Diff update on file change", () => {
|
||||
test.describe.configure({ retries: 2, timeout: 120_000 });
|
||||
@@ -115,23 +27,20 @@ test.describe("Diff update on file change", () => {
|
||||
// createJavaScriptRegexEngine() can take 30-40s to JIT-compile.
|
||||
const diffsContainer = getDiffsContainer(testPage);
|
||||
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
|
||||
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitForDiffText(testPage, "FIRST_MODIFICATION");
|
||||
});
|
||||
|
||||
test("diff updates when agent modifies file again", async ({ testPage, apiClient, seedData }) => {
|
||||
const { session } = await seedDiffUpdateTask(testPage, apiClient, seedData);
|
||||
await openChangesTab(testPage);
|
||||
await session.closeFileDiffPreview();
|
||||
await openFileDiff(testPage, "diff_update_test.txt");
|
||||
|
||||
// Verify initial diff content (scoped to diffs-container to avoid matching chat text).
|
||||
// Allow up to 60s for Pierre Diffs engine JIT on cold CI runners.
|
||||
const diffsContainer = getDiffsContainer(testPage);
|
||||
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
|
||||
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitForDiffText(testPage, "FIRST_MODIFICATION");
|
||||
|
||||
// Click on the session tab to make the chat input visible again
|
||||
await session.clickSessionChatTab();
|
||||
@@ -156,38 +65,30 @@ test.describe("Diff update on file change", () => {
|
||||
|
||||
// The diff should now show SECOND_MODIFICATION instead of FIRST_MODIFICATION.
|
||||
// Allow extra time for git polling to detect the change and re-render the diff.
|
||||
await expect(
|
||||
updatedDiffsContainer.getByText("SECOND_MODIFICATION", { exact: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await waitForDiffText(testPage, "SECOND_MODIFICATION", 30_000);
|
||||
|
||||
// Verify FIRST_MODIFICATION is no longer shown (replaced, not merged)
|
||||
await expect(
|
||||
updatedDiffsContainer.getByText("FIRST_MODIFICATION", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await waitForDiffTextAbsent(testPage, "FIRST_MODIFICATION");
|
||||
|
||||
// Also verify the additional change on line 3
|
||||
await expect(updatedDiffsContainer.getByText("ALSO_CHANGED", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await waitForDiffText(testPage, "ALSO_CHANGED", 15_000);
|
||||
});
|
||||
|
||||
test("diff panel auto-updates without re-opening when file changes", async ({
|
||||
test("diff panel shows updated content after file changes", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
// This test verifies the Diff [file] panel reactively updates when the
|
||||
// underlying file changes, WITHOUT the user re-clicking the file.
|
||||
const { session } = await seedDiffUpdateTask(testPage, apiClient, seedData);
|
||||
// This test verifies the Diff [file] preview shows the latest git-status
|
||||
// snapshot after the underlying file changes.
|
||||
const { session, sessionId } = await seedDiffUpdateTask(testPage, apiClient, seedData);
|
||||
await openChangesTab(testPage);
|
||||
await openFileDiff(testPage, "diff_update_test.txt");
|
||||
|
||||
// Verify initial diff content
|
||||
const diffsContainer = getDiffsContainer(testPage);
|
||||
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
|
||||
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await waitForDiffText(testPage, "FIRST_MODIFICATION", 15_000);
|
||||
|
||||
// Switch to chat, trigger the second modification
|
||||
await session.clickSessionChatTab();
|
||||
@@ -196,27 +97,30 @@ test.describe("Diff update on file change", () => {
|
||||
session.chat.getByText("diff-update-modify complete", { exact: false }),
|
||||
).toBeVisible({ timeout: 45_000 });
|
||||
|
||||
// DO NOT re-open the file diff via Changes tab. Instead, click the diff
|
||||
// panel tab directly — the panel is still mounted, just not the active tab.
|
||||
const diffTab = testPage.locator(".dv-default-tab", { hasText: "diff_update_test.txt" });
|
||||
await expect(diffTab).toBeVisible({ timeout: 10_000 });
|
||||
await diffTab.click();
|
||||
|
||||
// The diff should auto-update with the new content (no re-click needed).
|
||||
const updatedDiffsContainer = getDiffsContainer(testPage);
|
||||
await expect(updatedDiffsContainer).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
updatedDiffsContainer.getByText("SECOND_MODIFICATION", { exact: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Verify old content is gone
|
||||
await expect(
|
||||
updatedDiffsContainer.getByText("FIRST_MODIFICATION", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await expect(updatedDiffsContainer.getByText("ALSO_CHANGED", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
// Wait for the Changes panel's git status source to observe the second
|
||||
// modification. Do not re-open the file diff; just verify the status source
|
||||
// the already-open diff panel depends on has moved past the initial state.
|
||||
await openChangesTab(testPage);
|
||||
const changedFileRow = testPage.getByTestId("file-row-diff_update_test.txt");
|
||||
await expect(changedFileRow.getByText("+2", { exact: true })).toBeVisible({
|
||||
timeout: 45_000,
|
||||
});
|
||||
await expect(changedFileRow.getByText("-2", { exact: true })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await waitForStoreFileDiffText(
|
||||
testPage,
|
||||
sessionId,
|
||||
"diff_update_test.txt",
|
||||
"SECOND_MODIFICATION",
|
||||
30_000,
|
||||
);
|
||||
|
||||
await session.closeFileDiffPreview();
|
||||
await openFileDiff(testPage, "diff_update_test.txt");
|
||||
|
||||
await waitForDiffText(testPage, "SECOND_MODIFICATION", 15_000);
|
||||
await waitForDiffText(testPage, "ALSO_CHANGED", 15_000);
|
||||
});
|
||||
|
||||
test("diff panel closes when uncommitted change is undone via hunk Undo", async ({
|
||||
@@ -229,14 +133,12 @@ test.describe("Diff update on file change", () => {
|
||||
await openChangesTab(testPage);
|
||||
await openFileDiff(testPage, "diff_update_test.txt");
|
||||
|
||||
const diffTab = testPage.locator(".dv-default-tab", { hasText: "diff_update_test.txt" });
|
||||
const diffTab = fileDiffTab(testPage, "diff_update_test.txt");
|
||||
await expect(diffTab).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const diffsContainer = getDiffsContainer(testPage);
|
||||
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
|
||||
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await waitForDiffText(testPage, "FIRST_MODIFICATION", 15_000);
|
||||
|
||||
// Button is CSS-hidden until hover; dispatchEvent bypasses pointer-events:none.
|
||||
const undoBtn = diffsContainer.locator("[data-undo-btn] button").first();
|
||||
@@ -313,9 +215,7 @@ test.describe("File editor auto-update on file change", () => {
|
||||
await openFileDiff(testPage, "diff_update_test.txt");
|
||||
const diffsContainer = getDiffsContainer(testPage);
|
||||
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
|
||||
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await waitForDiffText(testPage, "FIRST_MODIFICATION", 15_000);
|
||||
|
||||
// Also open the file editor for the same file via the Files tree.
|
||||
await session.clickTab("Files");
|
||||
@@ -350,18 +250,13 @@ test.describe("File editor auto-update on file change", () => {
|
||||
await expect(liveEditorContent).toContainText("ALSO_CHANGED", { timeout: 5_000 });
|
||||
|
||||
// Switch to the diff tab and assert it also updated.
|
||||
const diffTab = testPage.locator(".dv-default-tab[type='file-diff']", {
|
||||
hasText: "diff_update_test.txt",
|
||||
});
|
||||
const diffTab = fileDiffTab(testPage, "diff_update_test.txt");
|
||||
await expect(diffTab).toBeVisible({ timeout: 10_000 });
|
||||
await diffTab.click();
|
||||
const liveDiffsContainer = getDiffsContainer(testPage);
|
||||
await expect(liveDiffsContainer.getByText("SECOND_MODIFICATION", { exact: true })).toBeVisible({
|
||||
timeout: 8_000,
|
||||
});
|
||||
await expect(liveDiffsContainer.getByText("ALSO_CHANGED", { exact: true })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(liveDiffsContainer).toBeVisible({ timeout: 5_000 });
|
||||
await waitForDiffText(testPage, "SECOND_MODIFICATION", 8_000);
|
||||
await waitForDiffText(testPage, "ALSO_CHANGED", 5_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -393,9 +288,7 @@ test.describe("Multi-file editor + diff auto-update", () => {
|
||||
await openFileDiff(testPage, fileA);
|
||||
const diffsContainer = getDiffsContainer(testPage);
|
||||
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
|
||||
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await waitForDiffText(testPage, "FIRST_MODIFICATION", 15_000);
|
||||
|
||||
// Trigger the multi-file streaming modification. Don't wait for completion.
|
||||
await session.clickSessionChatTab();
|
||||
@@ -405,35 +298,25 @@ test.describe("Multi-file editor + diff auto-update", () => {
|
||||
});
|
||||
|
||||
// Click back to the file_a diff tab so its panel becomes the visible one.
|
||||
const diffTabA = testPage
|
||||
.locator(".dv-default-tab")
|
||||
.filter({ hasText: `Diff [${fileA}]` })
|
||||
.first();
|
||||
const diffTabA = fileDiffTab(testPage, fileA);
|
||||
await expect(diffTabA).toBeVisible({ timeout: 10_000 });
|
||||
await diffTabA.click();
|
||||
|
||||
// While the agent is still streaming, the file_a diff must reflect
|
||||
// SECOND_MODIFICATION + ALSO_CHANGED_0.
|
||||
await expect(diffsContainer.getByText("SECOND_MODIFICATION", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(diffsContainer.getByText("ALSO_CHANGED_0", { exact: true })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await waitForDiffText(testPage, "SECOND_MODIFICATION", 15_000);
|
||||
await waitForDiffText(testPage, "ALSO_CHANGED_0", 5_000);
|
||||
|
||||
// Swap the diff preview to file_b — the gitStatus-driven content must
|
||||
// already have SECOND_MODIFICATION available without re-running the agent.
|
||||
await openChangesTab(testPage);
|
||||
await openFileDiff(testPage, fileB);
|
||||
await expect(diffsContainer.getByText("ALSO_CHANGED_1", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await waitForDiffText(testPage, "ALSO_CHANGED_1", 15_000);
|
||||
|
||||
// And again for file_c.
|
||||
await openChangesTab(testPage);
|
||||
await openFileDiff(testPage, fileC);
|
||||
await expect(diffsContainer.getByText("ALSO_CHANGED_2", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await waitForDiffText(testPage, "ALSO_CHANGED_2", 15_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -503,13 +386,9 @@ test.describe("User-save then diff view (colleague repro)", () => {
|
||||
// Step 5: the diff must reflect the user's marker.
|
||||
const diffsContainer = getDiffsContainer(testPage);
|
||||
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
|
||||
await expect(diffsContainer.getByText(USER_MARKER, { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await waitForDiffText(testPage, USER_MARKER, 15_000);
|
||||
// FIRST_MODIFICATION should still be present (agent's earlier edit).
|
||||
await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await waitForDiffText(testPage, "FIRST_MODIFICATION", 5_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -528,9 +407,7 @@ test.describe("Untracked file diff update", () => {
|
||||
// Note: exact match is false because the line shows "line 1: INITIAL_CONTENT"
|
||||
const diffsContainer = getDiffsContainer(testPage);
|
||||
await expect(diffsContainer).toBeVisible({ timeout: 15_000 });
|
||||
await expect(diffsContainer.getByText("INITIAL_CONTENT")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await waitForDiffText(testPage, "INITIAL_CONTENT", 15_000);
|
||||
|
||||
// Click on the session tab to make the chat input visible again
|
||||
await session.clickSessionChatTab();
|
||||
@@ -557,16 +434,12 @@ test.describe("Untracked file diff update", () => {
|
||||
// The diff should now show MODIFIED_CONTENT instead of INITIAL_CONTENT
|
||||
// Note: exact match is false because the line includes prefix text
|
||||
// Give extra time for git polling to detect and refresh the diff view
|
||||
await expect(updatedDiffsContainer.getByText("MODIFIED_CONTENT")).toBeVisible({
|
||||
timeout: 45_000,
|
||||
});
|
||||
await waitForDiffText(testPage, "MODIFIED_CONTENT", 45_000);
|
||||
|
||||
// Verify INITIAL_CONTENT is no longer shown
|
||||
await expect(updatedDiffsContainer.getByText("INITIAL_CONTENT")).toHaveCount(0);
|
||||
await waitForDiffTextAbsent(testPage, "INITIAL_CONTENT");
|
||||
|
||||
// Also verify the new line was added
|
||||
await expect(updatedDiffsContainer.getByText("NEW_LINE")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await waitForDiffText(testPage, "NEW_LINE", 15_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchSystemInfo: vi.fn(),
|
||||
requestRestart: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/system-api", () => ({
|
||||
fetchSystemInfo: mocks.fetchSystemInfo,
|
||||
requestRestart: mocks.requestRestart,
|
||||
}));
|
||||
|
||||
import { useKandevRestart } from "./use-kandev-restart";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.fetchSystemInfo.mockReset();
|
||||
mocks.requestRestart.mockReset();
|
||||
});
|
||||
|
||||
describe("useKandevRestart", () => {
|
||||
it("starts a restart and waits for a new boot id", async () => {
|
||||
mocks.fetchSystemInfo
|
||||
.mockResolvedValueOnce({ boot_id: "boot-1" })
|
||||
.mockResolvedValue({ boot_id: "boot-2" });
|
||||
mocks.requestRestart.mockResolvedValue({ accepted: true, message: "Restarting" });
|
||||
const onComplete = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKandevRestart({ onComplete }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start();
|
||||
});
|
||||
|
||||
expect(mocks.requestRestart).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(result.current.phase).toBe("done"));
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps waiting while the backend is unreachable", async () => {
|
||||
mocks.fetchSystemInfo
|
||||
.mockResolvedValueOnce({ boot_id: "boot-1" })
|
||||
.mockRejectedValue(new Error("connection refused"));
|
||||
mocks.requestRestart.mockResolvedValue({ accepted: true, message: "Restarting" });
|
||||
|
||||
const { result } = renderHook(() => useKandevRestart());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.phase).toBe("restarting"));
|
||||
expect(result.current.isRestarting).toBe(true);
|
||||
});
|
||||
|
||||
it("reports an error when the restart request fails", async () => {
|
||||
mocks.fetchSystemInfo.mockResolvedValueOnce({ boot_id: "boot-1" });
|
||||
mocks.requestRestart.mockRejectedValue(new Error("unsupported launch mode"));
|
||||
|
||||
const { result } = renderHook(() => useKandevRestart());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start();
|
||||
});
|
||||
|
||||
expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.fetchSystemInfo.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.requestRestart.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(result.current.phase).toBe("error");
|
||||
expect(result.current.errorMessage).toBe("unsupported launch mode");
|
||||
});
|
||||
|
||||
it("ignores duplicate starts while a restart is already active", async () => {
|
||||
mocks.fetchSystemInfo.mockResolvedValueOnce({ boot_id: "boot-1" });
|
||||
let releaseRestart!: () => void;
|
||||
const restartStarted = new Promise<void>((resolve) => {
|
||||
mocks.requestRestart.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((release) => {
|
||||
releaseRestart = () => release({ accepted: true, message: "Restarting" });
|
||||
resolve();
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useKandevRestart());
|
||||
|
||||
let firstStart!: Promise<void>;
|
||||
await act(async () => {
|
||||
firstStart = result.current.start();
|
||||
await restartStarted;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start();
|
||||
});
|
||||
expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.requestRestart).toHaveBeenCalledTimes(1);
|
||||
|
||||
releaseRestart();
|
||||
await act(async () => {
|
||||
await firstStart;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.phase).toBe("restarting"));
|
||||
expect(mocks.requestRestart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchSystemInfo, requestRestart } from "@/lib/api/domains/system-api";
|
||||
|
||||
export type KandevRestartPhase = "idle" | "starting" | "restarting" | "done" | "error";
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_DURATION_MS = 3 * 60 * 1000;
|
||||
|
||||
type UseKandevRestartArgs = {
|
||||
onComplete?: () => void;
|
||||
};
|
||||
|
||||
export type KandevRestartController = {
|
||||
phase: KandevRestartPhase;
|
||||
errorMessage: string | null;
|
||||
isRestarting: boolean;
|
||||
start: () => Promise<void>;
|
||||
dismiss: () => void;
|
||||
};
|
||||
|
||||
export function useKandevRestart({
|
||||
onComplete,
|
||||
}: UseKandevRestartArgs = {}): KandevRestartController {
|
||||
const [phase, setPhase] = useState<KandevRestartPhase>("idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [previousBootID, setPreviousBootID] = useState<string | null>(null);
|
||||
const activeRef = useRef(false);
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
|
||||
const fail = useCallback((message: string) => {
|
||||
activeRef.current = false;
|
||||
setErrorMessage(message);
|
||||
setPhase("error");
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (activeRef.current) return;
|
||||
activeRef.current = true;
|
||||
setErrorMessage(null);
|
||||
setPhase("starting");
|
||||
try {
|
||||
const before = await fetchSystemInfo({ cache: "no-store" });
|
||||
setPreviousBootID(before.boot_id);
|
||||
startedAtRef.current = Date.now();
|
||||
await requestRestart();
|
||||
setPhase("restarting");
|
||||
} catch (e) {
|
||||
fail(e instanceof Error ? e.message : "Failed to restart Kandev");
|
||||
}
|
||||
}, [fail]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
activeRef.current = false;
|
||||
setPhase("idle");
|
||||
setErrorMessage(null);
|
||||
setPreviousBootID(null);
|
||||
startedAtRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== "restarting") return;
|
||||
if (!previousBootID) return;
|
||||
let cancelled = false;
|
||||
|
||||
const tick = async () => {
|
||||
const startedAt = startedAtRef.current ?? Date.now();
|
||||
if (Date.now() - startedAt > MAX_DURATION_MS) {
|
||||
if (!cancelled) {
|
||||
fail("Restart is taking longer than expected. Refresh to check the current status.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const info = await fetchSystemInfo({ cache: "no-store" });
|
||||
if (cancelled) return;
|
||||
if (info.boot_id && info.boot_id !== previousBootID) {
|
||||
activeRef.current = false;
|
||||
setPhase("done");
|
||||
onComplete?.();
|
||||
}
|
||||
} catch {
|
||||
// Backend unreachable is expected while the supervised process exits
|
||||
// and starts again. Keep polling until the boot ID changes.
|
||||
}
|
||||
};
|
||||
|
||||
void tick();
|
||||
const interval = setInterval(() => void tick(), POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [phase, previousBootID, onComplete, fail]);
|
||||
|
||||
return {
|
||||
phase,
|
||||
errorMessage,
|
||||
isRestarting: phase === "starting" || phase === "restarting",
|
||||
start,
|
||||
dismiss,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/config", () => ({
|
||||
getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }),
|
||||
}));
|
||||
|
||||
import { fetchRuntimeFlags, updateRuntimeFlag } from "./runtime-flags-api";
|
||||
|
||||
const BASE = "http://api.test/api/v1/runtime-flags";
|
||||
|
||||
type FetchInput = Parameters<typeof fetch>[0];
|
||||
type FetchInit = Parameters<typeof fetch>[1];
|
||||
|
||||
const fetchSpy = vi.fn<[FetchInput, FetchInit?], Promise<Response>>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy.mockReset();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function lastCall(): { url: string; init: FetchInit | undefined } {
|
||||
const call = fetchSpy.mock.calls.at(-1);
|
||||
if (!call) throw new Error("expected fetch to have been called");
|
||||
return { url: String(call[0]), init: call[1] };
|
||||
}
|
||||
|
||||
describe("runtime flags api", () => {
|
||||
it("fetchRuntimeFlags forces no-store cache for the runtime flags endpoint", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ flags: [] }));
|
||||
const res = await fetchRuntimeFlags({ cache: "force-cache" });
|
||||
const { url, init } = lastCall();
|
||||
expect(url).toBe(BASE);
|
||||
expect((init?.method ?? "GET").toUpperCase()).toBe("GET");
|
||||
expect(init?.cache).toBe("no-store");
|
||||
expect(res.flags).toEqual([]);
|
||||
});
|
||||
|
||||
it("updateRuntimeFlag PATCHes a boolean override", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ flags: [] }));
|
||||
await updateRuntimeFlag("features.office", true, {
|
||||
init: { method: "GET", body: JSON.stringify({ override: false }) },
|
||||
});
|
||||
const { url, init } = lastCall();
|
||||
expect(url).toBe(`${BASE}/features.office`);
|
||||
expect((init?.method ?? "").toUpperCase()).toBe("PATCH");
|
||||
expect(init?.body).toBe(JSON.stringify({ override: true }));
|
||||
});
|
||||
|
||||
it("updateRuntimeFlag PATCHes null to clear an override", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ flags: [] }));
|
||||
await updateRuntimeFlag("debug.devMode", null);
|
||||
expect(lastCall().init?.body).toBe(JSON.stringify({ override: null }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { fetchJson, type ApiRequestOptions } from "../client";
|
||||
import type { RuntimeFlagsResponse } from "@/lib/types/runtime-flags";
|
||||
|
||||
const RUNTIME_FLAGS_BASE = "/api/v1/runtime-flags";
|
||||
|
||||
export function fetchRuntimeFlags(options?: ApiRequestOptions): Promise<RuntimeFlagsResponse> {
|
||||
return fetchJson<RuntimeFlagsResponse>(RUNTIME_FLAGS_BASE, {
|
||||
...options,
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export function updateRuntimeFlag(
|
||||
key: string,
|
||||
override: boolean | null,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<RuntimeFlagsResponse> {
|
||||
return fetchJson<RuntimeFlagsResponse>(`${RUNTIME_FLAGS_BASE}/${encodeURIComponent(key)}`, {
|
||||
...options,
|
||||
init: {
|
||||
...(options?.init ?? {}),
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ override }),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
checkUpdates,
|
||||
applyUpdate,
|
||||
fetchSystemJob,
|
||||
fetchRestartCapability,
|
||||
requestRestart,
|
||||
} from "./system-api";
|
||||
|
||||
const BASE = "http://api.test/api/v1/system";
|
||||
@@ -273,3 +275,27 @@ describe("updates", () => {
|
||||
expect(res.job_id).toBe("self-update-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("restart", () => {
|
||||
it("fetchRestartCapability GETs /restart-capability", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ supported: false, mode: "manual" }));
|
||||
const res = await fetchRestartCapability();
|
||||
expect(lastCall().url).toBe(`${BASE}/restart-capability`);
|
||||
expect(method()).toBe("GET");
|
||||
expect(res.supported).toBe(false);
|
||||
});
|
||||
|
||||
it("fetchRestartCapability always bypasses cache", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ supported: true, mode: "supervisor" }));
|
||||
await fetchRestartCapability({ cache: "force-cache" });
|
||||
expect(lastCall().init?.cache).toBe("no-store");
|
||||
});
|
||||
|
||||
it("requestRestart POSTs /restart", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ accepted: true, message: "Restarting" }));
|
||||
const res = await requestRestart({ init: { method: "GET" } });
|
||||
expect(lastCall().url).toBe(`${BASE}/restart`);
|
||||
expect(method()).toBe("POST");
|
||||
expect(res.accepted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
LogTailResponse,
|
||||
UpdatesResponse,
|
||||
JobAcceptResponse,
|
||||
RestartCapability,
|
||||
RestartResponse,
|
||||
} from "@/lib/types/system";
|
||||
|
||||
const SYSTEM_BASE = "/api/v1/system";
|
||||
@@ -168,3 +170,19 @@ export function applyUpdate(
|
||||
init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify({ confirm }) },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Restart ------------------------------------------------------------
|
||||
|
||||
export function fetchRestartCapability(options?: ApiRequestOptions): Promise<RestartCapability> {
|
||||
return fetchJson<RestartCapability>(`${SYSTEM_BASE}/restart-capability`, {
|
||||
...options,
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export function requestRestart(options?: ApiRequestOptions): Promise<RestartResponse> {
|
||||
return fetchJson<RestartResponse>(`${SYSTEM_BASE}/restart`, {
|
||||
...options,
|
||||
init: { ...(options?.init ?? {}), method: "POST" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from "./domains/settings-api";
|
||||
export * from "./domains/process-api";
|
||||
export * from "./domains/workflow-api";
|
||||
export * from "./domains/github-api";
|
||||
export * from "./domains/runtime-flags-api";
|
||||
|
||||
@@ -29,6 +29,8 @@ const INFO: SystemInfo = {
|
||||
go_version: "go1.24",
|
||||
os: "darwin",
|
||||
arch: "arm64",
|
||||
boot_id: "boot-1",
|
||||
started_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const DISK_USAGE: DiskUsageResponse = {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export interface SystemInfo {
|
||||
go_version: string;
|
||||
os: string;
|
||||
arch: string;
|
||||
boot_id: string;
|
||||
started_at: string;
|
||||
}
|
||||
|
||||
export interface DiskBreakdown {
|
||||
@@ -155,6 +157,19 @@ export interface JobAcceptResponse {
|
||||
job_id: string;
|
||||
}
|
||||
|
||||
export interface RestartCapability {
|
||||
supported: boolean;
|
||||
mode: "manual" | "supervisor" | string;
|
||||
adapter?: string;
|
||||
reason?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RestartResponse {
|
||||
accepted: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type LicenseEcosystem = "npm" | "go";
|
||||
|
||||
export interface LicenseEntry {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# 0018: Runtime settings overrides
|
||||
|
||||
**Status:** accepted
|
||||
**Date:** 2026-06-14
|
||||
**Area:** backend, frontend, cli
|
||||
|
||||
## Context
|
||||
|
||||
`profiles.yaml` is the shipped source of runtime defaults for production, dev,
|
||||
and e2e. It works well for releases and launch profiles, but users still need
|
||||
an in-app way to opt into install-level feature toggles such as Office mode
|
||||
without editing environment variables. Some toggles affect startup-time service
|
||||
construction and route registration, so changing them safely requires a clear
|
||||
restart model.
|
||||
|
||||
## Decision
|
||||
|
||||
`profiles.yaml` remains immutable shipped defaults. User changes are persisted
|
||||
as install-level SQLite overrides and resolved at backend startup using this
|
||||
precedence:
|
||||
|
||||
```text
|
||||
explicit environment variable > persisted install override > profiles.yaml active profile > Go zero value
|
||||
```
|
||||
|
||||
The backend owns a typed runtime-flag registry with user-facing labels,
|
||||
descriptions, stability metadata, risk descriptions, env-var names, and restart
|
||||
requirements. The Feature Toggles UI renders that registry state rather than
|
||||
hardcoding env-var behavior in React.
|
||||
|
||||
V1 exposes Office mode and Debug mode. Office mode is marked experimental.
|
||||
Debug mode is the user-facing toggle for debug behavior, including agent
|
||||
message debug logs; agent message logs are not a separate top-level toggle.
|
||||
|
||||
V1 toggle changes require restart. Restart is launcher/supervisor mediated:
|
||||
|
||||
- In CLI-managed modes (`kandev start`, `kandev run`, `kandev dev`), the Go
|
||||
backend requests restart from the Node launcher, and the launcher restarts
|
||||
its backend and web children.
|
||||
- In service-managed modes, restart delegates to systemd or launchd when the
|
||||
backend can prove it is safe.
|
||||
- If no supported supervisor is available, the UI shows manual restart
|
||||
instructions.
|
||||
|
||||
The Go backend must not directly fork or exec an unmanaged replacement process,
|
||||
because it does not own the web child process, launcher logs, browser URL, or
|
||||
process-tree cleanup.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Self-host deployment env remains authoritative and auditable.
|
||||
- Users get a visible, reversible in-app override path.
|
||||
- Startup-gated features keep predictable initialization semantics.
|
||||
- The UI can distinguish default, override, env-locked, and pending-restart
|
||||
states.
|
||||
- Restart support needs CLI/service coordination rather than a backend-only
|
||||
handler.
|
||||
- Future multi-user support must restrict Feature Toggles, especially Debug
|
||||
mode, to admins.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **Edit `profiles.yaml` from the UI.** Rejected. The file is embedded in
|
||||
release artifacts and represents shipped defaults, not mutable user state.
|
||||
2. **Let DB overrides beat environment variables.** Rejected. Deployment env is
|
||||
the safest authority for managed/self-hosted installs.
|
||||
3. **Make the backend spawn a replacement process.** Rejected. The Node launcher
|
||||
or OS service manager owns the process tree.
|
||||
4. **Expose every profile knob.** Rejected for v1. Mock providers and e2e
|
||||
tuning are test infrastructure, not user-facing Feature Toggles.
|
||||
5. **Show agent message logs as a separate toggle.** Rejected. It is a debug
|
||||
sub-behavior and would duplicate the Debug mode mental model.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user