feat(config): add YAML startup configuration parity (#2863)
This commit is contained in:
@@ -240,6 +240,15 @@ Runtime feature toggles add a SQLite-backed override tier managed through `Setti
|
||||
|
||||
Profile selection: `KANDEV_E2E_MOCK=true` → `e2e`, `KANDEV_DEBUG_DEV_MODE=true` or `KANDEV_DEBUG_PPROF_ENABLED=true` → `dev`, otherwise `prod`. The Go dev launcher (`apps/backend/internal/launcher/dev.go`) and `apps/web/e2e/fixtures/backend.ts` set only the selector — they no longer hardcode the underlying values.
|
||||
|
||||
Stable operator startup settings have a separate typed source contract in
|
||||
`apps/backend/internal/common/config/catalog.go` and `source.go`. The catalog
|
||||
owns each canonical YAML key, compatible environment alias, default, source
|
||||
provenance, sensitivity, and reviewed exclusion. Configuration discovery uses
|
||||
the first existing `config.yaml` candidate in working-directory, home, and
|
||||
`/etc/kandev` order; it does not merge files. Environment values override YAML,
|
||||
and consumers receive typed values or explicit child-process contracts instead
|
||||
of relying on YAML-to-public-environment copying.
|
||||
|
||||
For any task that adds, rolls out, promotes, graduates, or removes a release toggle, use `/runtime-feature-flags`. That skill contains the file-by-file checklist, disabled-path requirements, test commands, promotion procedure, and retired-identity removal steps; do not rely on an agent discovering an ADR or public docs. In brief: merge risky features off in every shipped profile, enable a selected install with an admin override or explicit environment, restart when required by registry metadata, then test. Change `prod:` to `"true"` for the all-user release while retaining the registry entry as a kill-switch. Remove the live flag after the feature is permanent, move its key and environment variable to the append-only retired identities in `runtimeflags/registry.go`, and never reuse either identity. Completeness tests cover the registry/profile/frontend contracts. Runtime overrides and restart support are documented in `docs/decisions/0018-runtime-settings-overrides.md` and `docs/decisions/0019-restart-supervisor.md`.
|
||||
|
||||
---
|
||||
|
||||
@@ -216,6 +216,7 @@ Client (WS) ← Orchestrator ← Lifecycle Manager ←──── stream update
|
||||
- **Dependency direction:** Shared admission limits used by task models and orchestrator/messagequeue belong in a neutral internal package; task/models must not import the higher-level orchestrator/messagequeue package.
|
||||
- Production Git commands must use `subproc.NewGitCommand` with a classified `subproc.RunGit*` helper, or hold a classified admission slot across streaming `Start`/`Wait`. Do not construct raw Git commands outside `internal/common/subproc`; choose `interactive`, `lifecycle`, or `background`.
|
||||
- **Cross-tier shared code belongs in `internal/common/`.** agentctl ships as a standalone binary uploaded into containers, so neither it nor the backend may own code the other imports — put shared logic in a neutral `internal/common/*` package instead of importing across the boundary or forking a copy (`ptyexec` is the worked example; `subproc`, `gitref`, `securityutil` are older ones). Duplicating to "avoid the dependency" is how the two PTY copies drifted.
|
||||
- **Startup configuration ownership:** `internal/common/config/catalog.go` and `source.go` own the stable operator catalog, discovery, precedence, provenance, and typed values; managed agentctl children receive a private subset contract. Do not reparse stable env in executors or copy YAML into public child environments.
|
||||
- **Pure computation that only matters on Windows should not carry `//go:build windows`.** One CI job runs on Windows and it tests a package allowlist, so tagged code is easily unverified — loginpty's copy of the Win32 quoting was compiled by no job at all. Keep platform API calls behind the tag, leave string/path helpers untagged, and add packages needing native coverage to the `test-windows` job in `.github/workflows/backend-tests.yml`.
|
||||
- **Event-bus wildcard parity:** New NATS wildcard subscriptions must verify equivalent `MemoryEventBus` semantics in `go test ./internal/events/bus`.
|
||||
- **Repository provider identity:** Provider-backed repositories are keyed by workspace, provider, normalized `provider_host` origin, full owner/namespace, and name. Persist `provider_host` when importing or resolving a remote; do not infer self-managed GitLab rows from owner/name alone. Legacy rows with an empty host have unknown identity and must fail closed for provider write/link operations.
|
||||
@@ -283,8 +284,6 @@ Enforced by `apps/backend/.golangci.yml` (errors on new code only):
|
||||
- Functions: ≤80 lines, ≤50 statements · Cyclomatic complexity: ≤15 · Cognitive complexity: ≤30
|
||||
- Nesting depth: ≤5 · Naked returns only in functions ≤30 lines · No duplicated blocks (≥150 tokens) · Repeated strings → constants (≥3 occurrences) · Revive's 800-effective-line file limit also applies to test files; put new tests in a new file instead of appending to an already-large test file.
|
||||
|
||||
When you hit a limit, extract a helper function. Prefer composition over growing a single function.
|
||||
|
||||
When a PR fixup touches backend code, run the CI-style changed-file linter locally from `apps/backend` with the PR base SHA before pushing, because CI enforces changed-file complexity thresholds:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -27,9 +27,11 @@ import (
|
||||
"github.com/kandev/kandev/internal/agentctl/server/config"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/instance"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/process"
|
||||
"github.com/kandev/kandev/internal/agentctl/tracing"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/githubauth"
|
||||
mcpserver "github.com/kandev/kandev/internal/mcp/server"
|
||||
"github.com/kandev/kandev/internal/profiles"
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -43,28 +45,54 @@ var (
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(runMain())
|
||||
}
|
||||
|
||||
func runMain() int {
|
||||
if code, handled := runGitHubUtilityCommand(); handled {
|
||||
os.Exit(code)
|
||||
return code
|
||||
}
|
||||
|
||||
// Dispatch kandev CLI subcommands before flag parsing or server startup.
|
||||
// When invoked as "agentctl kandev <cmd>", this runs the CLI and exits
|
||||
// without starting the HTTP server.
|
||||
if len(os.Args) > 1 && os.Args[1] == "kandev" {
|
||||
os.Exit(runKandevCLI(os.Args[2:]))
|
||||
return runKandevCLI(os.Args[2:])
|
||||
}
|
||||
|
||||
cleanupGitHubCLIShim, err := prepareGitHubCLIShim()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
return 1
|
||||
}
|
||||
defer cleanupGitHubCLIShim()
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// Load configuration
|
||||
cfg := config.Load()
|
||||
// Load configuration. Managed launches receive a validated, resolved child
|
||||
// contract from the backend. Direct invocations retain legacy environment
|
||||
// compatibility when that private contract is absent.
|
||||
startup, managed, err := config.StartupConfigFromEnv()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to load managed agentctl configuration: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
var cfg *config.Config
|
||||
if managed {
|
||||
cfg, err = config.LoadWithStartup(startup)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to load managed agentctl configuration: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
tracing.ConfigureEndpoint(startup.OTLPEndpoint)
|
||||
} else {
|
||||
cfg = config.Load()
|
||||
tracing.ConfigureEndpoint(cfg.OTLPEndpoint)
|
||||
}
|
||||
if _, _, err := profiles.ApplyProfile(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to apply profile defaults: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// Override with CLI flags if provided
|
||||
if *protocolFlag != "" {
|
||||
@@ -88,7 +116,7 @@ func main() {
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to initialize logger: %v\n", err)
|
||||
os.Exit(1)
|
||||
return 1
|
||||
}
|
||||
defer func() {
|
||||
_ = log.Sync()
|
||||
@@ -99,6 +127,7 @@ func main() {
|
||||
zap.String("log_level", cfg.LogLevel))
|
||||
|
||||
run(cfg, log)
|
||||
return 0
|
||||
}
|
||||
|
||||
func runGitHubUtilityCommand() (int, bool) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
||||
commonconfig "github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -31,6 +32,7 @@ type Launcher struct {
|
||||
port int
|
||||
logger *logger.Logger
|
||||
onUnexpectedExit func()
|
||||
startupConfig commonconfig.AgentctlStartupConfig
|
||||
|
||||
cmd *exec.Cmd
|
||||
exited chan struct{}
|
||||
@@ -65,6 +67,7 @@ type Config struct {
|
||||
Host string // Host to bind to (default: localhost)
|
||||
Port int // Control port (default: 39429)
|
||||
OnUnexpectedExit func() // Called once when the child exits without Stop.
|
||||
StartupConfig commonconfig.AgentctlStartupConfig
|
||||
}
|
||||
|
||||
// New creates a new Launcher.
|
||||
@@ -84,6 +87,7 @@ func New(cfg Config, log *logger.Logger) *Launcher {
|
||||
host: cfg.Host,
|
||||
port: cfg.Port,
|
||||
onUnexpectedExit: cfg.OnUnexpectedExit,
|
||||
startupConfig: cfg.StartupConfig,
|
||||
logger: log.WithFields(zap.String("component", "agentctl-launcher")),
|
||||
exited: make(chan struct{}),
|
||||
}
|
||||
@@ -230,8 +234,17 @@ func (l *Launcher) buildAndStartProcess(nonce string) error {
|
||||
// CommandContext sends SIGKILL on cancellation, preventing graceful shutdown.
|
||||
l.cmd = exec.Command(l.binaryPath, fmt.Sprintf("-port=%d", l.port))
|
||||
|
||||
// Inject bootstrap nonce; agentctl generates its own auth token.
|
||||
l.cmd.Env = append(os.Environ(), "AGENTCTL_BOOTSTRAP_NONCE="+nonce)
|
||||
// Inject bootstrap nonce and the resolved child contract. Remove inherited
|
||||
// copies first so a managed child cannot observe a conflicting host value.
|
||||
overrides := []string{"AGENTCTL_BOOTSTRAP_NONCE=" + nonce}
|
||||
if l.startupConfig.Configured {
|
||||
encoded, err := commonconfig.EncodeAgentctlStartupConfig(l.startupConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
overrides = append(overrides, commonconfig.InternalAgentctlStartupConfigEnv+"="+encoded)
|
||||
}
|
||||
l.cmd.Env = environmentWithOverrides(os.Environ(), overrides...)
|
||||
l.cmd.SysProcAttr = buildSysProcAttr()
|
||||
|
||||
pipeWrite, err := setupLivenessPipe(l.cmd)
|
||||
@@ -274,6 +287,27 @@ func (l *Launcher) buildAndStartProcess(nonce string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func environmentWithOverrides(base []string, overrides ...string) []string {
|
||||
keys := make(map[string]struct{}, len(overrides))
|
||||
for _, entry := range overrides {
|
||||
key, _, ok := strings.Cut(entry, "=")
|
||||
if ok && key != "" {
|
||||
keys[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(base)+len(overrides))
|
||||
for _, entry := range base {
|
||||
key, _, ok := strings.Cut(entry, "=")
|
||||
if ok {
|
||||
if _, overridden := keys[key]; overridden {
|
||||
continue
|
||||
}
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
return append(result, overrides...)
|
||||
}
|
||||
|
||||
// performHandshake retrieves agentctl's self-generated auth token using the nonce.
|
||||
// On failure it kills the process and closes the liveness pipe.
|
||||
func (l *Launcher) performHandshake(ctx context.Context, nonce string) (string, error) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -24,6 +25,22 @@ func TestLauncherConfigSupportsUnexpectedExitCallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentWithOverridesRemovesInheritedValues(t *testing.T) {
|
||||
got := environmentWithOverrides(
|
||||
[]string{"KANDEV_ACP_IDLE_TIMEOUT=host", "PATH=/bin", "DUP=old"},
|
||||
"KANDEV_ACP_IDLE_TIMEOUT=resolved",
|
||||
"DUP=new",
|
||||
)
|
||||
|
||||
joined := strings.Join(got, "\n")
|
||||
if strings.Count(joined, "KANDEV_ACP_IDLE_TIMEOUT=") != 1 || !strings.Contains(joined, "KANDEV_ACP_IDLE_TIMEOUT=resolved") {
|
||||
t.Fatalf("resolved timeout override = %v", got)
|
||||
}
|
||||
if strings.Count(joined, "DUP=") != 1 || !strings.Contains(joined, "DUP=new") {
|
||||
t.Fatalf("duplicate override = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherUnexpectedExitCallbackRunsForCleanAndFailedExit(t *testing.T) {
|
||||
for _, exitCode := range []int{0, 7} {
|
||||
t.Run(fmt.Sprintf("exit-%d", exitCode), func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
commonconfig "github.com/kandev/kandev/internal/common/config"
|
||||
)
|
||||
|
||||
func validateAgentctlStartupConfig(startup commonconfig.AgentctlStartupConfig) error {
|
||||
if !startup.Configured {
|
||||
return nil
|
||||
}
|
||||
return startup.Validate()
|
||||
}
|
||||
|
||||
func agentctlStartupEnvironment(startup commonconfig.AgentctlStartupConfig) map[string]string {
|
||||
if !startup.Configured {
|
||||
return nil
|
||||
}
|
||||
raw, err := commonconfig.EncodeAgentctlStartupConfig(startup)
|
||||
if err != nil {
|
||||
// Executor entry points validate the contract before calling this helper.
|
||||
// Keep the helper total for test-only direct environment builders.
|
||||
return nil
|
||||
}
|
||||
return map[string]string{commonconfig.InternalAgentctlStartupConfigEnv: raw}
|
||||
}
|
||||
|
||||
func mergeAgentctlStartupEnvironment(base map[string]string, startup commonconfig.AgentctlStartupConfig) map[string]string {
|
||||
result := make(map[string]string, len(base)+1)
|
||||
for key, value := range base {
|
||||
result[key] = value
|
||||
}
|
||||
for key, value := range agentctlStartupEnvironment(startup) {
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
commonconfig "github.com/kandev/kandev/internal/common/config"
|
||||
)
|
||||
|
||||
func testAgentctlStartupConfig() commonconfig.AgentctlStartupConfig {
|
||||
return commonconfig.AgentctlStartupConfig{
|
||||
Configured: true,
|
||||
IdleTimeout: 2 * time.Hour,
|
||||
IdleReaperInterval: 3 * time.Minute,
|
||||
NotificationQueueCapacity: 4096,
|
||||
OTLPEndpoint: "http://collector:4318",
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentctlStartupContractReachesContainerSpriteAndSSHEnvironments(t *testing.T) {
|
||||
startup := testAgentctlStartupConfig()
|
||||
encoded, err := commonconfig.EncodeAgentctlStartupConfig(startup)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeAgentctlStartupConfig: %v", err)
|
||||
}
|
||||
|
||||
containerEnv, err := (&ContainerManager{}).buildEnvVars(ContainerConfig{
|
||||
AgentConfig: agents.NewMockAgent(),
|
||||
AgentctlStartupConfig: startup,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildEnvVars: %v", err)
|
||||
}
|
||||
if !containsEnvValue(containerEnv, commonconfig.InternalAgentctlStartupConfigEnv, encoded) {
|
||||
t.Fatalf("container environment did not carry the resolved contract: %v", containerEnv)
|
||||
}
|
||||
if got := countEnvKey(containerEnv, commonconfig.InternalAgentctlStartupConfigEnv); got != 1 {
|
||||
t.Fatalf("container environment contains %d startup contracts, want exactly one: %v", got, containerEnv)
|
||||
}
|
||||
|
||||
spriteEnv := (&SpritesExecutor{}).buildSpriteEnv(map[string]string{
|
||||
commonconfig.InternalAgentctlStartupConfigEnv: "host-value",
|
||||
}, startup)
|
||||
if !containsEnvValue(spriteEnv, commonconfig.InternalAgentctlStartupConfigEnv, encoded) {
|
||||
t.Fatalf("sprite environment did not override the host contract: %v", spriteEnv)
|
||||
}
|
||||
|
||||
sshEnv := sshAgentctlLaunchEnv(map[string]string{
|
||||
commonconfig.InternalAgentctlStartupConfigEnv: "host-value",
|
||||
}, "nonce", startup)
|
||||
if sshEnv[commonconfig.InternalAgentctlStartupConfigEnv] != encoded {
|
||||
t.Fatalf("SSH environment contract = %q, want %q", sshEnv[commonconfig.InternalAgentctlStartupConfigEnv], encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentctlStartupContractRejectsInvalidLaunchValues(t *testing.T) {
|
||||
startup := testAgentctlStartupConfig()
|
||||
startup.NotificationQueueCapacity = 1
|
||||
if err := validateAgentctlStartupConfig(startup); err == nil {
|
||||
t.Fatal("validateAgentctlStartupConfig accepted an invalid queue capacity")
|
||||
}
|
||||
}
|
||||
|
||||
func containsEnvValue(env []string, key, want string) bool {
|
||||
prefix := key + "="
|
||||
for _, entry := range env {
|
||||
if strings.HasPrefix(entry, prefix) && strings.TrimPrefix(entry, prefix) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func countEnvKey(env []string, key string) int {
|
||||
prefix := key + "="
|
||||
count := 0
|
||||
for _, entry := range env {
|
||||
if strings.HasPrefix(entry, prefix) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
"github.com/kandev/kandev/internal/agent/docker"
|
||||
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
||||
commonconfig "github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/common/constants"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/gitconfigenv"
|
||||
@@ -56,10 +57,11 @@ type ContainerConfig struct {
|
||||
McpMode string
|
||||
McpProviders []string
|
||||
McpProfile *mcpprofile.Context
|
||||
PrepareScript string // Script to run inside container before agent starts (e.g., clone repo)
|
||||
ImageTagOverride string // If set, replaces the agent runtime's default image (e.g. profile.config.image_tag)
|
||||
LocalClonePath string // Host path for file:// repository clone URLs; mounted read-only at the same path.
|
||||
BootstrapNonce string // one-time nonce for agentctl handshake (set internally)
|
||||
PrepareScript string // Script to run inside container before agent starts (e.g., clone repo)
|
||||
ImageTagOverride string // If set, replaces the agent runtime's default image (e.g. profile.config.image_tag)
|
||||
LocalClonePath string // Host path for file:// repository clone URLs; mounted read-only at the same path.
|
||||
BootstrapNonce string // one-time nonce for agentctl handshake (set internally)
|
||||
AgentctlStartupConfig commonconfig.AgentctlStartupConfig
|
||||
Metadata map[string]interface{} // Optional metadata (e.g., office runtime dir)
|
||||
// BaseBranches maps RepositoryName → base branch ref; forwarded into
|
||||
// agentctl's CreateInstanceRequest so each WorkspaceTracker resolves
|
||||
@@ -655,6 +657,9 @@ func (cm *ContainerManager) expandMountSource(source, workspacePath string) stri
|
||||
|
||||
// buildEnvVars builds environment variables for the container
|
||||
func (cm *ContainerManager) buildEnvVars(config ContainerConfig) ([]string, error) {
|
||||
if err := validateAgentctlStartupConfig(config.AgentctlStartupConfig); err != nil {
|
||||
return nil, fmt.Errorf("invalid agentctl startup configuration: %w", err)
|
||||
}
|
||||
ag := config.AgentConfig
|
||||
rt := ag.Runtime()
|
||||
env := make([]string, 0)
|
||||
@@ -733,10 +738,25 @@ func (cm *ContainerManager) buildEnvVars(config ContainerConfig) ([]string, erro
|
||||
if config.BootstrapNonce != "" {
|
||||
env = append(env, "AGENTCTL_BOOTSTRAP_NONCE="+config.BootstrapNonce)
|
||||
}
|
||||
env = removeEnvKey(env, commonconfig.InternalAgentctlStartupConfigEnv)
|
||||
for key, value := range agentctlStartupEnvironment(config.AgentctlStartupConfig) {
|
||||
env = append(env, key+"="+value)
|
||||
}
|
||||
|
||||
return env, nil
|
||||
}
|
||||
|
||||
func removeEnvKey(env []string, key string) []string {
|
||||
prefix := key + "="
|
||||
filtered := env[:0]
|
||||
for _, entry := range env {
|
||||
if !strings.HasPrefix(entry, prefix) {
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// generateBootstrapNonce creates a cryptographically random 32-byte hex-encoded nonce.
|
||||
func generateBootstrapNonce() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/process"
|
||||
"github.com/kandev/kandev/internal/agentruntime"
|
||||
commonconfig "github.com/kandev/kandev/internal/common/config"
|
||||
mcpprofile "github.com/kandev/kandev/internal/mcp/profile"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
@@ -411,6 +412,7 @@ type ExecutorCreateRequest struct {
|
||||
McpProfile *mcpprofile.Context
|
||||
AuthToken string // Previously handshaken agentctl token for reconnects
|
||||
BootstrapNonce string // Stored nonce for re-handshake after container restart
|
||||
AgentctlStartupConfig commonconfig.AgentctlStartupConfig
|
||||
|
||||
// OnProgress is an optional callback for streaming preparation progress.
|
||||
// Executors that perform multi-step setup (e.g. Sprites, remote Docker) can
|
||||
|
||||
@@ -163,6 +163,9 @@ func (r *DockerExecutor) HealthCheck(_ context.Context) error {
|
||||
|
||||
func (r *DockerExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (instance *ExecutorInstance, err error) {
|
||||
baseCtx := preparationContext(ctx)
|
||||
if err := validateAgentctlStartupConfig(req.AgentctlStartupConfig); err != nil {
|
||||
return nil, fmt.Errorf("invalid agentctl startup configuration: %w", err)
|
||||
}
|
||||
if _, err := validateRemoteContributions(req.RemoteContributions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -288,6 +291,7 @@ func (r *DockerExecutor) buildContainerLaunchConfig(req *ExecutorCreateRequest)
|
||||
BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches),
|
||||
RemoteContributions: req.RemoteContributions,
|
||||
ContributionDestinations: req.ContributionDestinations,
|
||||
AgentctlStartupConfig: req.AgentctlStartupConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,9 @@ func (r *SpritesExecutor) ResumeRemoteInstance(_ context.Context, req *ExecutorC
|
||||
|
||||
func (r *SpritesExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) {
|
||||
baseCtx := preparationContext(ctx)
|
||||
if err := validateAgentctlStartupConfig(req.AgentctlStartupConfig); err != nil {
|
||||
return nil, fmt.Errorf("invalid agentctl startup configuration: %w", err)
|
||||
}
|
||||
if _, err := validateRemoteContributions(req.RemoteContributions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -232,7 +235,7 @@ func (r *SpritesExecutor) preflightGitHubCredentialBroker(
|
||||
stepCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
cmd := sprite.CommandContext(stepCtx, "sh", "-c", command)
|
||||
cmd.Env = r.buildSpriteEnv(env)
|
||||
cmd.Env = r.buildSpriteEnv(env, req.AgentctlStartupConfig)
|
||||
return cmd.CombinedOutput()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ func (r *SpritesExecutor) runAuthSetupScripts(
|
||||
|
||||
stepCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
cmd := sprite.CommandContext(stepCtx, "sh", "-c", method.SetupScript)
|
||||
cmd.Env = r.buildSpriteEnv(req.Env)
|
||||
cmd.Env = r.buildSpriteEnv(req.Env, req.AgentctlStartupConfig)
|
||||
out, err := cmd.CombinedOutput()
|
||||
cancel()
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
sprites "github.com/superfly/sprites-go"
|
||||
"go.uber.org/zap"
|
||||
|
||||
commonconfig "github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/githubauth"
|
||||
)
|
||||
|
||||
@@ -128,9 +129,13 @@ func extractUploadHTTPStatus(msg string) int {
|
||||
return code
|
||||
}
|
||||
|
||||
func (r *SpritesExecutor) buildSpriteEnv(env map[string]string) []string {
|
||||
result := make([]string, 0, len(env))
|
||||
for k, v := range env {
|
||||
func (r *SpritesExecutor) buildSpriteEnv(env map[string]string, startup ...commonconfig.AgentctlStartupConfig) []string {
|
||||
merged := env
|
||||
if len(startup) > 0 {
|
||||
merged = mergeAgentctlStartupEnvironment(env, startup[0])
|
||||
}
|
||||
result := make([]string, 0, len(merged))
|
||||
for k, v := range merged {
|
||||
if k == githubauth.CredentialHelperPathEnv {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ func (r *SpritesExecutor) runPrepareScript(
|
||||
|
||||
r.logger.Debug("running prepare script")
|
||||
cmd := sprite.CommandContext(stepCtx, "bash", "-c", script)
|
||||
cmd.Env = r.buildSpriteEnv(req.Env)
|
||||
cmd.Env = r.buildSpriteEnv(req.Env, req.AgentctlStartupConfig)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/process"
|
||||
"github.com/kandev/kandev/internal/agentruntime"
|
||||
commonconfig "github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/secrets"
|
||||
)
|
||||
@@ -192,6 +193,9 @@ func (r *SSHExecutor) workdirRoot(md map[string]interface{}) string {
|
||||
// instead of starting a second remote agentctl on top of the live one.
|
||||
func (r *SSHExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) {
|
||||
baseCtx := preparationContext(ctx)
|
||||
if err := validateAgentctlStartupConfig(req.AgentctlStartupConfig); err != nil {
|
||||
return nil, fmt.Errorf("invalid agentctl startup configuration: %w", err)
|
||||
}
|
||||
resumed, ok := r.resumedStateForCreate(req)
|
||||
if ok {
|
||||
return r.buildResumedInstance(req, resumed), nil
|
||||
@@ -384,6 +388,7 @@ func (r *SSHExecutor) startAndForwardAgentctl(
|
||||
env := sshAgentctlLaunchEnv(
|
||||
managedGitCredentialBrokerEnv(sshRemoteContributionEnv(req, agentctlBin)),
|
||||
nonce,
|
||||
req.AgentctlStartupConfig,
|
||||
)
|
||||
shell := sshShellForRemote(req.Metadata, platform)
|
||||
controlPort, pid, err := startRemoteAgentctl(ctx, client, shell, agentctlBin, taskDir, sessionDir, env, r.logger)
|
||||
@@ -428,7 +433,7 @@ func (r *SSHExecutor) startAndForwardAgentctl(
|
||||
return instancePort, pid, fwd, authToken, nil
|
||||
}
|
||||
|
||||
func sshAgentctlLaunchEnv(base map[string]string, nonce string) map[string]string {
|
||||
func sshAgentctlLaunchEnv(base map[string]string, nonce string, startup ...commonconfig.AgentctlStartupConfig) map[string]string {
|
||||
env := make(map[string]string, len(base))
|
||||
for key, value := range base {
|
||||
env[key] = value
|
||||
@@ -439,6 +444,11 @@ func sshAgentctlLaunchEnv(base map[string]string, nonce string) map[string]strin
|
||||
delete(env, "AGENTCTL_AUTH_TOKEN")
|
||||
env["AGENTCTL_BOOTSTRAP_NONCE"] = nonce
|
||||
env["AGENTCTL_LISTEN_HOST"] = sshAgentctlLoopbackHost
|
||||
if len(startup) > 0 {
|
||||
for key, value := range agentctlStartupEnvironment(startup[0]) {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/agent/runtime/activity"
|
||||
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
||||
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
|
||||
commonconfig "github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/secrets"
|
||||
@@ -157,6 +158,10 @@ type Manager struct {
|
||||
// SSH/remote rows — their process lives on another host.
|
||||
standaloneHostPID atomic.Int64
|
||||
|
||||
// agentctlStartupConfig is the resolved child contract applied to every
|
||||
// managed agentctl launch path.
|
||||
agentctlStartupConfig commonconfig.AgentctlStartupConfig
|
||||
|
||||
// managedGoCache provides the opt-in GOCACHE for host-local executions.
|
||||
// System storage wiring installs it after settings persistence is ready.
|
||||
managedGoCache ManagedGoCacheEnvironmentProvider
|
||||
@@ -240,6 +245,17 @@ func (m *Manager) SetStandaloneHostPID(pid int) {
|
||||
m.standaloneHostPID.Store(int64(pid))
|
||||
}
|
||||
|
||||
// SetAgentctlStartupConfig wires the resolved backend-owned agentctl values
|
||||
// into every executor request. Remote and container executors serialize this
|
||||
// contract explicitly instead of inheriting the backend environment.
|
||||
func (m *Manager) SetAgentctlStartupConfig(startup commonconfig.AgentctlStartupConfig) error {
|
||||
if err := startup.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
m.agentctlStartupConfig = startup
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewManager creates a new lifecycle manager.
|
||||
// The executorRegistry manages multiple runtimes (Docker, Standalone, etc.) for task-specific execution.
|
||||
// The fallbackPolicy controls behavior when a requested runtime is unavailable.
|
||||
|
||||
@@ -734,6 +734,7 @@ func (m *Manager) prepareExecutionCreateRequest(
|
||||
PreviousExecutionID: info.AgentExecutionID,
|
||||
AuthToken: m.revealRuntimeSecret(ctx, info.Metadata, MetadataKeyAuthTokenSecret),
|
||||
BootstrapNonce: m.revealRuntimeSecret(ctx, info.Metadata, MetadataKeyBootstrapNonceSecret),
|
||||
AgentctlStartupConfig: m.agentctlStartupConfig,
|
||||
RemoteContributions: remoteContributions,
|
||||
ContributionDestinations: contributionDestinations,
|
||||
},
|
||||
|
||||
@@ -801,6 +801,7 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st
|
||||
McpProfile: reqWithWorktree.McpProfile,
|
||||
AuthToken: m.revealRuntimeSecret(ctx, metadata, MetadataKeyAuthTokenSecret),
|
||||
BootstrapNonce: m.revealRuntimeSecret(ctx, metadata, MetadataKeyBootstrapNonceSecret),
|
||||
AgentctlStartupConfig: m.agentctlStartupConfig,
|
||||
OnProgress: onProgress,
|
||||
RemoteContributions: remoteContributions,
|
||||
ContributionDestinations: contributionDestinations,
|
||||
|
||||
@@ -300,6 +300,9 @@ type Config struct {
|
||||
// (opencode acp). The process manager uses the adapter's return value to
|
||||
// decide whether to kill the entire process group on shutdown.
|
||||
RequiresProcessKill bool
|
||||
|
||||
// NotificationQueueCapacity is the server-resolved ACP inbound queue size.
|
||||
NotificationQueueCapacity int
|
||||
}
|
||||
|
||||
// ToSharedConfig converts this Config to the shared.Config used by transport adapters.
|
||||
@@ -317,20 +320,21 @@ func (c *Config) ToSharedConfig() *shared.Config {
|
||||
}
|
||||
}
|
||||
return &shared.Config{
|
||||
WorkDir: c.WorkDir,
|
||||
AutoApprove: c.AutoApprove,
|
||||
ApprovalPolicy: c.ApprovalPolicy,
|
||||
McpServers: mcpServers,
|
||||
AgentID: c.AgentID,
|
||||
AgentName: c.AgentName,
|
||||
BaseURL: c.BaseURL,
|
||||
AuthHeader: c.AuthHeader,
|
||||
AuthValue: c.AuthValue,
|
||||
Headers: c.Headers,
|
||||
Extra: c.Extra,
|
||||
AssumeMcpSse: c.AssumeMcpSse,
|
||||
AssumeMcpHttp: c.AssumeMcpHttp,
|
||||
RequiresProcessKill: c.RequiresProcessKill,
|
||||
WorkDir: c.WorkDir,
|
||||
AutoApprove: c.AutoApprove,
|
||||
ApprovalPolicy: c.ApprovalPolicy,
|
||||
McpServers: mcpServers,
|
||||
AgentID: c.AgentID,
|
||||
AgentName: c.AgentName,
|
||||
BaseURL: c.BaseURL,
|
||||
AuthHeader: c.AuthHeader,
|
||||
AuthValue: c.AuthValue,
|
||||
Headers: c.Headers,
|
||||
Extra: c.Extra,
|
||||
AssumeMcpSse: c.AssumeMcpSse,
|
||||
AssumeMcpHttp: c.AssumeMcpHttp,
|
||||
RequiresProcessKill: c.RequiresProcessKill,
|
||||
NotificationQueueCapacity: c.NotificationQueueCapacity,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,8 +76,12 @@ const (
|
||||
)
|
||||
|
||||
// acpNotifQueueCapacity returns the per-connection inbound notification queue
|
||||
// capacity, honoring KANDEV_ACP_NOTIF_QUEUE when set and parseable.
|
||||
func acpNotifQueueCapacity() int {
|
||||
// capacity. Managed servers pass the resolved value explicitly; the legacy
|
||||
// environment fallback remains for directly constructed adapters.
|
||||
func acpNotifQueueCapacity(configured ...int) int {
|
||||
if len(configured) > 0 && configured[0] != 0 {
|
||||
return clampACPNotifQueueCapacity(configured[0])
|
||||
}
|
||||
raw := os.Getenv("KANDEV_ACP_NOTIF_QUEUE")
|
||||
if raw == "" {
|
||||
return acpNotifQueueDefault
|
||||
@@ -86,6 +90,10 @@ func acpNotifQueueCapacity() int {
|
||||
if err != nil || n <= 0 {
|
||||
return acpNotifQueueDefault
|
||||
}
|
||||
return clampACPNotifQueueCapacity(n)
|
||||
}
|
||||
|
||||
func clampACPNotifQueueCapacity(n int) int {
|
||||
if n < acpNotifQueueMin {
|
||||
return acpNotifQueueMin
|
||||
}
|
||||
@@ -454,7 +462,7 @@ func (a *Adapter) Initialize(ctx context.Context) error {
|
||||
// down. The internal notifQueueCapacity channel sits in front of this
|
||||
// queue and is drained by our update worker. Requires a coder/acp-go-sdk
|
||||
// fork with WithMaxQueuedNotifications; see go.mod replace directive.
|
||||
notifQueueCap := acpNotifQueueCapacity()
|
||||
notifQueueCap := acpNotifQueueCapacity(a.cfg.NotificationQueueCapacity)
|
||||
a.acpConn = acp.NewClientSideConnection(a.acpClient, a.stdin, a.stdout,
|
||||
acp.WithMaxQueuedNotifications(notifQueueCap))
|
||||
a.acpConn.SetLogger(slog.Default().With("component", "acp-conn"))
|
||||
|
||||
@@ -54,3 +54,10 @@ func TestACPNotifQueueCapacity(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestACPNotifQueueCapacityUsesExplicitStartupValue(t *testing.T) {
|
||||
t.Setenv("KANDEV_ACP_NOTIF_QUEUE", "1024")
|
||||
if got := acpNotifQueueCapacity(4096); got != 4096 {
|
||||
t.Fatalf("explicit queue capacity = %d, want 4096", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ type Config struct {
|
||||
// adapter's return value to decide whether to kill the entire process
|
||||
// group on shutdown so MCP child processes don't leak.
|
||||
RequiresProcessKill bool
|
||||
|
||||
// NotificationQueueCapacity is the resolved ACP inbound notification queue
|
||||
// capacity. Zero keeps compatibility for directly constructed test configs.
|
||||
NotificationQueueCapacity int
|
||||
}
|
||||
|
||||
// GetPermissionTimeout returns the configured permission timeout or the default.
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
commonconfig "github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/gitconfigenv"
|
||||
"github.com/kandev/kandev/internal/githubauth"
|
||||
mcpprofile "github.com/kandev/kandev/internal/mcp/profile"
|
||||
@@ -88,6 +89,13 @@ type Config struct {
|
||||
// rely on the default.
|
||||
IdleReaperInterval time.Duration
|
||||
|
||||
// NotificationQueueCapacity is the resolved ACP inbound notification
|
||||
// queue capacity for every instance created by this server.
|
||||
NotificationQueueCapacity int
|
||||
|
||||
// OTLPEndpoint is the resolved endpoint used by agentctl transport tracing.
|
||||
OTLPEndpoint string
|
||||
|
||||
// mu protects BootstrapNonce from concurrent access during handshake.
|
||||
mu sync.Mutex
|
||||
}
|
||||
@@ -202,6 +210,10 @@ type InstanceConfig struct {
|
||||
// ProcessBufferMaxBytes caps per-process output buffer size
|
||||
ProcessBufferMaxBytes int64
|
||||
|
||||
// NotificationQueueCapacity is the ACP inbound notification queue size
|
||||
// inherited from the server startup contract.
|
||||
NotificationQueueCapacity int
|
||||
|
||||
// SessionID is the session ID for this agent instance (used in MCP tool calls)
|
||||
SessionID string
|
||||
|
||||
@@ -269,8 +281,50 @@ type InstanceConfig struct {
|
||||
WorkspaceSourceRoots []string
|
||||
}
|
||||
|
||||
// Load loads the configuration from environment variables.
|
||||
// Load loads the configuration from environment variables. Managed launches
|
||||
// should use LoadWithStartup so their resolved child contract is explicit.
|
||||
func Load() *Config {
|
||||
return load(nil)
|
||||
}
|
||||
|
||||
// LoadWithStartup loads the configuration and applies the resolved backend
|
||||
// startup contract. The contract is validated before it is used so a managed
|
||||
// child cannot silently fall back to a different timeout or queue size.
|
||||
func LoadWithStartup(startup commonconfig.AgentctlStartupConfig) (*Config, error) {
|
||||
if err := startup.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return load(&startup), nil
|
||||
}
|
||||
|
||||
// StartupConfigFromEnv reads the private managed-child contract. A missing
|
||||
// variable means agentctl was started directly and should retain legacy
|
||||
// environment compatibility. A present but malformed variable is fatal to
|
||||
// that managed launch.
|
||||
func StartupConfigFromEnv() (commonconfig.AgentctlStartupConfig, bool, error) {
|
||||
raw, found := os.LookupEnv(commonconfig.InternalAgentctlStartupConfigEnv)
|
||||
if !found {
|
||||
return commonconfig.AgentctlStartupConfig{}, false, nil
|
||||
}
|
||||
startup, err := commonconfig.DecodeAgentctlStartupConfig(raw)
|
||||
if err != nil {
|
||||
return commonconfig.AgentctlStartupConfig{}, true, err
|
||||
}
|
||||
return startup, true, nil
|
||||
}
|
||||
|
||||
func load(startup *commonconfig.AgentctlStartupConfig) *Config {
|
||||
idleTimeout := getEnvDuration("KANDEV_ACP_IDLE_TIMEOUT", time.Hour)
|
||||
idleReaperInterval := getEnvDuration("KANDEV_ACP_IDLE_REAPER_INTERVAL", time.Minute)
|
||||
notificationQueueCapacity := getEnvInt("KANDEV_ACP_NOTIF_QUEUE", 131072)
|
||||
otlpEndpoint := getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "")
|
||||
if startup != nil {
|
||||
idleTimeout = startup.IdleTimeout
|
||||
idleReaperInterval = startup.IdleReaperInterval
|
||||
notificationQueueCapacity = startup.NotificationQueueCapacity
|
||||
otlpEndpoint = startup.OTLPEndpoint
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
Port: getEnvInt("AGENTCTL_PORT", 39429),
|
||||
Ports: PortConfig{
|
||||
@@ -286,14 +340,16 @@ func Load() *Config {
|
||||
HealthCheckInterval: getEnvInt("AGENTCTL_HEALTH_CHECK_INTERVAL", 5),
|
||||
ProcessBufferMaxBytes: getEnvInt64("AGENTCTL_PROCESS_BUFFER_MAX_BYTES", 2*1024*1024),
|
||||
},
|
||||
ShellEnabled: getEnvBool("AGENTCTL_SHELL_ENABLED", true),
|
||||
LogLevel: getEnvWithFallback("AGENTCTL_LOG_LEVEL", "KANDEV_LOG_LEVEL", "info"),
|
||||
LogFormat: getEnv("AGENTCTL_LOG_FORMAT", "json"),
|
||||
McpLogFile: getEnv("KANDEV_MCP_LOG_FILE", ""),
|
||||
VscodeCommand: getEnv("AGENTCTL_VSCODE_COMMAND", "code-server"),
|
||||
ListenHostOverride: getEnv("AGENTCTL_LISTEN_HOST", ""),
|
||||
IdleTimeout: getEnvDuration("KANDEV_ACP_IDLE_TIMEOUT", time.Hour),
|
||||
IdleReaperInterval: getEnvDuration("KANDEV_ACP_IDLE_REAPER_INTERVAL", time.Minute),
|
||||
ShellEnabled: getEnvBool("AGENTCTL_SHELL_ENABLED", true),
|
||||
LogLevel: getEnvWithFallback("AGENTCTL_LOG_LEVEL", "KANDEV_LOG_LEVEL", "info"),
|
||||
LogFormat: getEnv("AGENTCTL_LOG_FORMAT", "json"),
|
||||
McpLogFile: getEnv("KANDEV_MCP_LOG_FILE", ""),
|
||||
VscodeCommand: getEnv("AGENTCTL_VSCODE_COMMAND", "code-server"),
|
||||
ListenHostOverride: getEnv("AGENTCTL_LISTEN_HOST", ""),
|
||||
IdleTimeout: idleTimeout,
|
||||
IdleReaperInterval: idleReaperInterval,
|
||||
NotificationQueueCapacity: notificationQueueCapacity,
|
||||
OTLPEndpoint: otlpEndpoint,
|
||||
}
|
||||
|
||||
// Bootstrap nonce mode: agentctl generates its own token and the backend
|
||||
@@ -360,19 +416,20 @@ func generateSelfToken() string {
|
||||
// If port is 0, it should be allocated by the caller.
|
||||
func (c *Config) NewInstanceConfig(port int, overrides *InstanceOverrides) *InstanceConfig {
|
||||
cfg := &InstanceConfig{
|
||||
Port: port,
|
||||
Protocol: c.Defaults.Protocol,
|
||||
AgentCommand: c.Defaults.AgentCommand,
|
||||
WorkDir: c.Defaults.WorkDir,
|
||||
AutoStart: c.Defaults.AutoStart,
|
||||
AutoApprovePermissions: c.Defaults.AutoApprovePermissions,
|
||||
ShellEnabled: c.ShellEnabled,
|
||||
LogLevel: c.LogLevel,
|
||||
LogFormat: c.LogFormat,
|
||||
ProcessBufferMaxBytes: c.Defaults.ProcessBufferMaxBytes,
|
||||
VscodeCommand: c.VscodeCommand,
|
||||
McpMode: "task",
|
||||
AuthToken: c.AuthToken,
|
||||
Port: port,
|
||||
Protocol: c.Defaults.Protocol,
|
||||
AgentCommand: c.Defaults.AgentCommand,
|
||||
WorkDir: c.Defaults.WorkDir,
|
||||
AutoStart: c.Defaults.AutoStart,
|
||||
AutoApprovePermissions: c.Defaults.AutoApprovePermissions,
|
||||
ShellEnabled: c.ShellEnabled,
|
||||
LogLevel: c.LogLevel,
|
||||
LogFormat: c.LogFormat,
|
||||
ProcessBufferMaxBytes: c.Defaults.ProcessBufferMaxBytes,
|
||||
NotificationQueueCapacity: c.NotificationQueueCapacity,
|
||||
VscodeCommand: c.VscodeCommand,
|
||||
McpMode: "task",
|
||||
AuthToken: c.AuthToken,
|
||||
}
|
||||
|
||||
applyOverrides(cfg, overrides)
|
||||
|
||||
@@ -8,8 +8,39 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
commonconfig "github.com/kandev/kandev/internal/common/config"
|
||||
)
|
||||
|
||||
func TestLoadWithStartupUsesExplicitManagedValues(t *testing.T) {
|
||||
t.Setenv("KANDEV_ACP_IDLE_TIMEOUT", "bad")
|
||||
t.Setenv("KANDEV_ACP_IDLE_REAPER_INTERVAL", "bad")
|
||||
t.Setenv("KANDEV_ACP_NOTIF_QUEUE", "1024")
|
||||
t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://inherited:4318")
|
||||
|
||||
startup := commonconfig.AgentctlStartupConfig{
|
||||
Configured: true,
|
||||
IdleTimeout: 2 * time.Hour,
|
||||
IdleReaperInterval: 3 * time.Minute,
|
||||
NotificationQueueCapacity: 4096,
|
||||
OTLPEndpoint: "http://configured:4318",
|
||||
}
|
||||
cfg, err := LoadWithStartup(startup)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithStartup: %v", err)
|
||||
}
|
||||
if cfg.IdleTimeout != startup.IdleTimeout || cfg.IdleReaperInterval != startup.IdleReaperInterval {
|
||||
t.Fatalf("managed durations = %s/%s, want %s/%s", cfg.IdleTimeout, cfg.IdleReaperInterval, startup.IdleTimeout, startup.IdleReaperInterval)
|
||||
}
|
||||
if cfg.NotificationQueueCapacity != startup.NotificationQueueCapacity {
|
||||
t.Fatalf("managed queue capacity = %d, want %d", cfg.NotificationQueueCapacity, startup.NotificationQueueCapacity)
|
||||
}
|
||||
if cfg.OTLPEndpoint != startup.OTLPEndpoint {
|
||||
t.Fatalf("managed OTLP endpoint = %q, want %q", cfg.OTLPEndpoint, startup.OTLPEndpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewInstanceConfigNormalizesMcpProviders(t *testing.T) {
|
||||
cfg := (&Config{}).NewInstanceConfig(0, &InstanceOverrides{
|
||||
McpProviders: []string{" GITLAB ", "unsupported", "github", "github"},
|
||||
|
||||
@@ -1209,14 +1209,15 @@ func (m *Manager) buildAdapterConfig() error {
|
||||
}
|
||||
}
|
||||
m.adapterCfg = &adapter.Config{
|
||||
WorkDir: m.cfg.WorkDir,
|
||||
AutoApprove: m.cfg.AutoApprovePermissions,
|
||||
ApprovalPolicy: m.cfg.ApprovalPolicy,
|
||||
McpServers: mcpServers,
|
||||
AgentID: m.cfg.AgentType, // From registry (e.g., "auggie", "amp", "claude-code")
|
||||
AssumeMcpSse: m.cfg.AssumeMcpSse,
|
||||
AssumeMcpHttp: m.cfg.AssumeMcpHttp,
|
||||
RequiresProcessKill: m.cfg.RequiresProcessKill,
|
||||
WorkDir: m.cfg.WorkDir,
|
||||
AutoApprove: m.cfg.AutoApprovePermissions,
|
||||
ApprovalPolicy: m.cfg.ApprovalPolicy,
|
||||
McpServers: mcpServers,
|
||||
AgentID: m.cfg.AgentType, // From registry (e.g., "auggie", "amp", "claude-code")
|
||||
AssumeMcpSse: m.cfg.AssumeMcpSse,
|
||||
AssumeMcpHttp: m.cfg.AssumeMcpHttp,
|
||||
RequiresProcessKill: m.cfg.RequiresProcessKill,
|
||||
NotificationQueueCapacity: m.cfg.NotificationQueueCapacity,
|
||||
}
|
||||
|
||||
// Configure one-shot mode when a continue command is provided.
|
||||
|
||||
@@ -425,6 +425,28 @@ func TestBuildAdapterConfig_StripEnvRemovesDeclaredVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAdapterConfigForwardsNotificationQueueCapacity(t *testing.T) {
|
||||
m := &Manager{
|
||||
cfg: &config.InstanceConfig{
|
||||
AgentArgs: []string{"cat"},
|
||||
WorkDir: t.TempDir(),
|
||||
AgentEnv: []string{"PATH=/usr/bin"},
|
||||
Protocol: agent.ProtocolACP,
|
||||
NotificationQueueCapacity: 4096,
|
||||
},
|
||||
logger: newTestLogger(t),
|
||||
}
|
||||
|
||||
if err := m.buildAdapterConfig(); err != nil {
|
||||
t.Fatalf("buildAdapterConfig: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = m.adapter.Close() })
|
||||
|
||||
if got := m.adapterCfg.NotificationQueueCapacity; got != 4096 {
|
||||
t.Fatalf("adapter notification queue capacity = %d, want 4096", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartOneShotPreservesConfiguredTempEnvironment(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
workDir := t.TempDir()
|
||||
|
||||
@@ -25,11 +25,33 @@ import (
|
||||
const serviceName = "kandev-agentctl"
|
||||
|
||||
var (
|
||||
initOnce sync.Once
|
||||
tracerProvider trace.TracerProvider = noop.NewTracerProvider()
|
||||
sdkProvider *sdktrace.TracerProvider
|
||||
initOnce sync.Once
|
||||
tracerProvider trace.TracerProvider = noop.NewTracerProvider()
|
||||
sdkProvider *sdktrace.TracerProvider
|
||||
endpointMu sync.RWMutex
|
||||
configuredEndpoint string
|
||||
configuredSet bool
|
||||
)
|
||||
|
||||
// ConfigureEndpoint sets the resolved startup endpoint before the first
|
||||
// tracer is requested. Managed backend and agentctl processes use this to
|
||||
// avoid depending on inherited environment values.
|
||||
func ConfigureEndpoint(endpoint string) {
|
||||
endpointMu.Lock()
|
||||
configuredEndpoint = endpoint
|
||||
configuredSet = true
|
||||
endpointMu.Unlock()
|
||||
}
|
||||
|
||||
func resolvedEndpoint() string {
|
||||
endpointMu.RLock()
|
||||
defer endpointMu.RUnlock()
|
||||
if configuredSet {
|
||||
return configuredEndpoint
|
||||
}
|
||||
return os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
}
|
||||
|
||||
func initTracing() {
|
||||
// Always register the W3C propagator so Inject/Extract work
|
||||
// even if this process doesn't export spans.
|
||||
@@ -40,7 +62,7 @@ func initTracing() {
|
||||
),
|
||||
)
|
||||
|
||||
endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
endpoint := resolvedEndpoint()
|
||||
if endpoint == "" {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ func provideAgentctlLauncher(
|
||||
l, cleanup, err := launcher.Provide(ctx, launcher.Config{
|
||||
Host: cfg.Agent.StandaloneHost,
|
||||
Port: cfg.Agent.StandalonePort,
|
||||
StartupConfig: cfg.ManagedAgentctlStartupConfig(),
|
||||
OnUnexpectedExit: availability.MarkUnavailable,
|
||||
}, log)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,7 +2,9 @@ package backendapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -90,7 +92,7 @@ func provideLifecycleManager(
|
||||
}
|
||||
credsMgr.AddProvider(credentials.NewEnvProvider("KANDEV_"))
|
||||
credsMgr.AddProvider(credentials.NewAugmentSessionProvider())
|
||||
if credsFile := os.Getenv("KANDEV_CREDENTIALS_FILE"); credsFile != "" {
|
||||
if credsFile := credentialFilePath(cfg); credsFile != "" {
|
||||
credsMgr.AddProvider(credentials.NewFileProvider(credsFile))
|
||||
}
|
||||
|
||||
@@ -122,6 +124,9 @@ func provideLifecycleManager(
|
||||
preparerRegistry.Register(models.ExecutorTypeSSH, lifecycle.NewSSHPreparer(log))
|
||||
lifecycleMgr.SetPreparerRegistry(preparerRegistry)
|
||||
lifecycleMgr.SetSecretStore(secretStore)
|
||||
if err := lifecycleMgr.SetAgentctlStartupConfig(cfg.ManagedAgentctlStartupConfig()); err != nil {
|
||||
return nil, fmt.Errorf("configure managed agentctl startup: %w", err)
|
||||
}
|
||||
// Record the standalone agentctl control-server PID (populated by
|
||||
// provideAgentctlLauncher, which runs before this) so local/standalone
|
||||
// executor rows carry a real host-local liveness handle.
|
||||
@@ -148,3 +153,10 @@ func provideLifecycleManager(
|
||||
zap.Int("agent_types", len(agentRegistry.List())))
|
||||
return lifecycleMgr, nil
|
||||
}
|
||||
|
||||
func credentialFilePath(cfg *config.Config) string {
|
||||
if cfg != nil {
|
||||
return strings.TrimSpace(cfg.Credentials.File)
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv("KANDEV_CREDENTIALS_FILE"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package backendapp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
)
|
||||
|
||||
func TestCredentialFilePathUsesTypedStartupConfig(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Credentials.File = "/srv/kandev/credentials.json"
|
||||
t.Setenv("KANDEV_CREDENTIALS_FILE", "/tmp/legacy.json")
|
||||
|
||||
if got := credentialFilePath(cfg); got != "/srv/kandev/credentials.json" {
|
||||
t.Fatalf("credential file = %q, want typed startup path", got)
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,7 @@ func provideGateway(
|
||||
gitlabSvc *gitlab.Service,
|
||||
referenceValidator entityrefs.SubmissionValidator,
|
||||
dataDir string,
|
||||
lspMaxConnections ...int,
|
||||
) (*gateways.Gateway, *notificationservice.Service, *notificationcontroller.Controller, *terminalservice.Service, error) {
|
||||
gateway, err := gateways.Provide(log)
|
||||
if err != nil {
|
||||
@@ -142,7 +143,7 @@ func provideGateway(
|
||||
scriptSvc := &scriptServiceAdapter{taskSvc: taskSvc}
|
||||
if lifecycleMgr != nil {
|
||||
gateway.SetLifecycleManager(lifecycleMgr, userSvc, scriptSvc)
|
||||
gateway.SetLSPHandler(lifecycleMgr, userSvc)
|
||||
gateway.SetLSPHandler(lifecycleMgr, userSvc, lspMaxConnections...)
|
||||
gateway.SetVscodeProxy(lifecycleMgr)
|
||||
gateway.SetPortProxy(lifecycleMgr)
|
||||
gateway.SetPortTunnel(lifecycleMgr)
|
||||
|
||||
@@ -572,6 +572,8 @@ type routeParams struct {
|
||||
devMode bool
|
||||
httpPort int
|
||||
features config.FeaturesConfig
|
||||
planCoalesceWindow time.Duration
|
||||
planCoalesceWindowConfigured bool
|
||||
homeDir string
|
||||
interimSettingsInterlockToken string
|
||||
log *logger.Logger
|
||||
@@ -580,7 +582,12 @@ type routeParams struct {
|
||||
// registerRoutes sets up all HTTP and WebSocket routes on the given router.
|
||||
func registerRoutes(p routeParams) {
|
||||
workflowCtrl := workflowcontroller.NewController(p.services.Workflow)
|
||||
planService := taskservice.NewPlanService(p.taskRepo, p.eventBus, p.log)
|
||||
var planService *taskservice.PlanService
|
||||
if p.planCoalesceWindowConfigured {
|
||||
planService = taskservice.NewPlanService(p.taskRepo, p.eventBus, p.log, p.planCoalesceWindow)
|
||||
} else {
|
||||
planService = taskservice.NewPlanService(p.taskRepo, p.eventBus, p.log)
|
||||
}
|
||||
// Per-user task scoping for plan reads/writes (opt-in auth).
|
||||
planService.SetTaskAuthorizer(p.taskSvc.AuthorizeTaskAccess)
|
||||
clarificationStore := clarification.NewStore(2 * time.Hour)
|
||||
|
||||
@@ -28,7 +28,10 @@ import (
|
||||
// Common packages
|
||||
"github.com/kandev/kandev/internal/backendapp/ownershiplock"
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/common/constants"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/common/subproc"
|
||||
"github.com/kandev/kandev/internal/profiles"
|
||||
|
||||
// Event bus
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
@@ -54,6 +57,7 @@ import (
|
||||
runtimeskill "github.com/kandev/kandev/internal/agent/runtime/lifecycle/skill"
|
||||
agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller"
|
||||
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
|
||||
agentctltracing "github.com/kandev/kandev/internal/agentctl/tracing"
|
||||
"github.com/kandev/kandev/internal/utility/profilebinding"
|
||||
|
||||
// WebSocket gateway
|
||||
@@ -205,6 +209,12 @@ func Run(args []string, build BuildInfo) int {
|
||||
fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if _, _, err := profiles.ApplyProfile(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to apply profile defaults: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
constants.ApplyPreparationTimeout(cfg.Tasks.PreparationTimeout)
|
||||
subproc.ConfigureCaps(cfg.Limits.GHMaxConcurrent, cfg.Limits.GitMaxConcurrent)
|
||||
|
||||
// Apply command-line flag overrides (flags take precedence over config/env)
|
||||
if parsedFlags.Port > 0 {
|
||||
@@ -213,6 +223,7 @@ func Run(args []string, build BuildInfo) int {
|
||||
if parsedFlags.LogLevel != "" {
|
||||
cfg.Logging.Level = parsedFlags.LogLevel
|
||||
}
|
||||
agentctltracing.ConfigureEndpoint(cfg.Observability.OTLPEndpoint)
|
||||
|
||||
// Acquire runtime-state ownership before any shared-state initialization.
|
||||
// The lock remains held through logger and service cleanup, so a second
|
||||
@@ -776,6 +787,7 @@ func startGatewayAndServe(
|
||||
repos.Notification, repos.Task, repos.Terminal, services.GitHub, services.GitLab,
|
||||
referenceValidator,
|
||||
cfg.ResolvedHomeDir(),
|
||||
cfg.Limits.LSPMaxConnections,
|
||||
)
|
||||
if terminalSvc != nil {
|
||||
services.Terminal = terminalSvc
|
||||
@@ -904,6 +916,7 @@ func startGatewayAndServe(
|
||||
}
|
||||
scheduling := startSchedulingRuntime(
|
||||
ctx, repos, services, eventBus, orchestratorSvc, runProcessorSvc, log,
|
||||
runsscheduler.TickIntervalFromConfig(cfg.Office.SchedulerTickMs),
|
||||
)
|
||||
addCleanup(scheduling.Stop)
|
||||
var restoreQuiesceOnce sync.Once
|
||||
@@ -951,6 +964,7 @@ func startGatewayAndServe(
|
||||
OrchestratorShutdown: func() { _ = orchestratorSvc.Stop() },
|
||||
RestoreQuiesce: restoreQuiesce,
|
||||
MessageQueue: orchestratorSvc.GetMessageQueue(),
|
||||
MessageQueueConfig: queueConfiguration(cfg),
|
||||
TaskSessions: repos.Task,
|
||||
})
|
||||
storageComposition, err := provideStorageComposition(
|
||||
@@ -1429,10 +1443,11 @@ func startSchedulingRuntime(
|
||||
orchestratorSvc *orchestrator.Service,
|
||||
runProcessorSvc *officeservice.Service,
|
||||
log *logger.Logger,
|
||||
tickInterval time.Duration,
|
||||
) *schedulingRuntime {
|
||||
log.Info("Global run processor wired to orchestrator StartTask")
|
||||
orchScheduler := officeservice.NewSchedulerIntegration(
|
||||
runProcessorSvc, runsscheduler.TickIntervalFromEnv(),
|
||||
runProcessorSvc, tickInterval,
|
||||
)
|
||||
// Office task-handoffs prompt enrichment. The HandoffService is
|
||||
// constructed alongside the HTTP routes (helpers.go); we stash the
|
||||
@@ -1458,11 +1473,11 @@ func startSchedulingRuntime(
|
||||
// orchScheduler.Tick on both periodic ticks and event-driven signals.
|
||||
runScheduler := runsscheduler.New(
|
||||
orchScheduler, runsSvc.SubscribeSignal(),
|
||||
runsscheduler.TickIntervalFromEnv(), log,
|
||||
tickInterval, log,
|
||||
)
|
||||
runScheduler.Start(ctx)
|
||||
log.Info("Runs scheduler started",
|
||||
zap.Duration("tick", runsscheduler.TickIntervalFromEnv()))
|
||||
zap.Duration("tick", tickInterval))
|
||||
// Phase 5 (ADR-0004): start the shared cron loop. The routines handler
|
||||
// degrades to a no-op when routineSvc is nil, so omitting Office's
|
||||
// scheduler is safe when features.office is off.
|
||||
@@ -1986,7 +2001,7 @@ func buildHTTPServer(
|
||||
// X-Forwarded-Host feeds the port-scoped cookie-name resolver; honor it
|
||||
// only from the same trusted proxies that may rewrite it (an untrusted
|
||||
// value is stripped with a warning, so the resolver falls back to Host).
|
||||
trusted := configureTrustedProxies(router, log)
|
||||
trusted := configureTrustedProxies(router, log, cfg.Server.TrustedProxies)
|
||||
router.Use(authhttpmw.StripUntrustedForwardedHost(trusted, log))
|
||||
router.Use(httpmw.RequestLogger(log, kandevName))
|
||||
router.Use(httpmw.OtelTracing(kandevName))
|
||||
@@ -2080,6 +2095,8 @@ func buildHTTPServer(
|
||||
devMode: cfg.Debug.DevMode || cfg.Debug.PprofEnabled,
|
||||
httpPort: resolvedHTTPPort(cfg),
|
||||
features: cfg.Features,
|
||||
planCoalesceWindow: time.Duration(cfg.Planning.CoalesceWindowMs) * time.Millisecond,
|
||||
planCoalesceWindowConfigured: true,
|
||||
homeDir: cfg.ResolvedHomeDir(),
|
||||
interimSettingsInterlockToken: interimSettingsInterlockToken,
|
||||
log: log,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
commonconfig "github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/system/queuesettings"
|
||||
systemsettings "github.com/kandev/kandev/internal/system/settings"
|
||||
@@ -46,6 +47,61 @@ func TestResolveQueueMaxPerSessionPrecedence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveQueueMaxPerSessionUsesYAMLConfigurationBeforePersistedSetting(t *testing.T) {
|
||||
pool := newMessageQueueSettingsTestPool(t)
|
||||
raw, err := systemsettings.NewStore(pool)
|
||||
if err != nil {
|
||||
t.Fatalf("new system settings store: %v", err)
|
||||
}
|
||||
if err := queuesettings.NewStore(raw).Save(context.Background(), queuesettings.Settings{
|
||||
MaxPerSession: 6, MergeEnabled: true, AutoMergeEnabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("save setting: %v", err)
|
||||
}
|
||||
|
||||
cfg := &commonconfig.Config{
|
||||
MessageQueue: commonconfig.MessageQueueConfig{MaxPerSession: 14},
|
||||
Source: commonconfig.ConfigSource{Values: map[string]commonconfig.SettingSource{
|
||||
"messageQueue.maxPerSession": commonconfig.SourceConfiguration,
|
||||
}},
|
||||
}
|
||||
t.Setenv(queuesettings.EnvironmentVariable, "")
|
||||
startup := queueConfiguration(cfg)
|
||||
resolution := resolveQueueSettings(pool, testLogger(t), startup)
|
||||
if resolution.Effective.MaxPerSession != 14 ||
|
||||
resolution.Effective.Source != queuesettings.SourceConfiguration ||
|
||||
!resolution.Effective.Locked {
|
||||
t.Fatalf("resolution = %+v, want locked YAML configuration value", resolution.Effective)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveQueueMaxPerSessionYAMLZeroLocksUnlimitedCapacity(t *testing.T) {
|
||||
pool := newMessageQueueSettingsTestPool(t)
|
||||
raw, err := systemsettings.NewStore(pool)
|
||||
if err != nil {
|
||||
t.Fatalf("new system settings store: %v", err)
|
||||
}
|
||||
if err := queuesettings.NewStore(raw).Save(context.Background(), queuesettings.Settings{
|
||||
MaxPerSession: 6, MergeEnabled: true, AutoMergeEnabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("save setting: %v", err)
|
||||
}
|
||||
|
||||
cfg := &commonconfig.Config{
|
||||
MessageQueue: commonconfig.MessageQueueConfig{MaxPerSession: 0},
|
||||
Source: commonconfig.ConfigSource{Values: map[string]commonconfig.SettingSource{
|
||||
"messageQueue.maxPerSession": commonconfig.SourceConfiguration,
|
||||
}},
|
||||
}
|
||||
t.Setenv(queuesettings.EnvironmentVariable, "")
|
||||
resolution := resolveQueueSettings(pool, testLogger(t), queueConfiguration(cfg))
|
||||
if resolution.Effective.MaxPerSession != 0 ||
|
||||
resolution.Effective.Source != queuesettings.SourceConfiguration ||
|
||||
!resolution.Effective.Locked {
|
||||
t.Fatalf("resolution = %+v, want locked YAML unlimited value", resolution.Effective)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveQueueMergeEnabledPrecedence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -97,7 +97,7 @@ func provideOrchestrator(
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("init message queue repo: %w", err)
|
||||
}
|
||||
queueSettings := resolveQueueSettings(pool, log).Effective
|
||||
queueSettings := resolveQueueSettings(pool, log, queueConfiguration(cfg)).Effective
|
||||
maxPerSession := queueSettings.MaxPerSession
|
||||
mergeEnabled := queueSettings.MergeEnabled
|
||||
autoMergeEnabled := queueSettings.AutoMergeEnabled
|
||||
@@ -287,8 +287,8 @@ func githubCredentialBrokerEndpoint(cfg *config.Config) string {
|
||||
// falling back to messagequeue.DefaultMaxPerSession (10) when unset or invalid.
|
||||
// Values <= 0 disable the cap entirely (callers can still flood queues — only
|
||||
// useful in tests / specialized deployments).
|
||||
func resolveQueueMaxPerSession(pool *db.Pool, log *logger.Logger) int {
|
||||
return resolveQueueSettings(pool, log).Effective.MaxPerSession
|
||||
func resolveQueueMaxPerSession(pool *db.Pool, log *logger.Logger, startup ...queuesettings.Configuration) int {
|
||||
return resolveQueueSettings(pool, log, startup...).Effective.MaxPerSession
|
||||
}
|
||||
|
||||
// resolveQueueMergeEnabled honors the persisted message queue setting,
|
||||
@@ -308,7 +308,11 @@ func resolveQueueAutoMergeEnabled(pool *db.Pool, log *logger.Logger) bool {
|
||||
// back to defaults when unset, invalid, or the store is unavailable — and
|
||||
// resolves them against the KANDEV_QUEUE_MAX_PER_SESSION environment
|
||||
// override.
|
||||
func resolveQueueSettings(pool *db.Pool, log *logger.Logger) queuesettings.Resolution {
|
||||
func resolveQueueSettings(
|
||||
pool *db.Pool,
|
||||
log *logger.Logger,
|
||||
startup ...queuesettings.Configuration,
|
||||
) queuesettings.Resolution {
|
||||
var configured *queuesettings.Settings
|
||||
if pool != nil {
|
||||
rawStore, err := systemsettings.NewStore(pool)
|
||||
@@ -322,7 +326,7 @@ func resolveQueueSettings(pool *db.Pool, log *logger.Logger) queuesettings.Resol
|
||||
}
|
||||
}
|
||||
}
|
||||
resolution, err := queuesettings.Resolve(configured, queuesettings.ReadEnvironment())
|
||||
resolution, err := queuesettings.Resolve(configured, queuesettings.ReadEnvironment(), startup...)
|
||||
if err != nil {
|
||||
log.Warn("Failed to resolve message queue settings, using defaults", zap.Error(err))
|
||||
return queuesettings.Resolution{Response: queuesettings.Response{
|
||||
@@ -341,6 +345,13 @@ func resolveQueueSettings(pool *db.Pool, log *logger.Logger) queuesettings.Resol
|
||||
return resolution
|
||||
}
|
||||
|
||||
func queueConfiguration(cfg *config.Config) queuesettings.Configuration {
|
||||
if cfg == nil || cfg.SourceFor("messageQueue.maxPerSession") != config.SourceConfiguration {
|
||||
return queuesettings.Configuration{}
|
||||
}
|
||||
return queuesettings.Configuration{Value: cfg.MessageQueue.MaxPerSession, Present: true}
|
||||
}
|
||||
|
||||
func resolveEventNamespace(cfg *config.Config) string {
|
||||
if cfg == nil {
|
||||
return defaultEventNamespace
|
||||
|
||||
@@ -11,8 +11,9 @@ import (
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
// trustedProxiesEnv lists the reverse proxies whose X-Forwarded-For header
|
||||
// the backend trusts. Comma-separated IPs and CIDRs.
|
||||
// trustedProxiesEnv is the legacy environment alias for the trusted-proxy
|
||||
// setting. It lists reverse proxies whose X-Forwarded-For header the backend
|
||||
// trusts as comma-separated IPs and CIDRs.
|
||||
const trustedProxiesEnv = "KANDEV_TRUSTED_PROXIES"
|
||||
|
||||
// emptyTrustedProxyEntry is the placeholder name used when a blank component
|
||||
@@ -21,11 +22,11 @@ const trustedProxiesEnv = "KANDEV_TRUSTED_PROXIES"
|
||||
// closed like any other unparsable entry.
|
||||
const emptyTrustedProxyEntry = "<empty>"
|
||||
|
||||
const trustedProxiesWarningMsg = "KANDEV_TRUSTED_PROXIES contains invalid entries; ignoring X-Forwarded-For entirely"
|
||||
const trustedProxiesWarningMsg = "configured trusted-proxy setting contains invalid entries; ignoring X-Forwarded-For entirely"
|
||||
|
||||
// resolveTrustedProxies parses the comma-separated KANDEV_TRUSTED_PROXIES
|
||||
// value into gin-compatible proxy origins (bare IPs or CIDRs). An entirely
|
||||
// blank value means "no trusted proxies" (trusted nil, no invalid entries).
|
||||
// resolveTrustedProxies parses the comma-separated environment override into
|
||||
// gin-compatible proxy origins (bare IPs or CIDRs). An entirely blank value
|
||||
// means "no trusted proxies" (trusted nil, no invalid entries).
|
||||
// Any unparsable entry, including a blank component inside a nonblank value,
|
||||
// makes the whole list untrustworthy: trusted is nil then (fail closed) and
|
||||
// invalid names the bad values for the startup warning. The returned strings
|
||||
@@ -35,7 +36,11 @@ func resolveTrustedProxies(raw string) (trusted []string, invalid []string) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
return resolveTrustedProxyEntries(strings.Split(raw, ","))
|
||||
}
|
||||
|
||||
func resolveTrustedProxyEntries(entries []string) (trusted []string, invalid []string) {
|
||||
for _, part := range entries {
|
||||
entry := strings.TrimSpace(part)
|
||||
if entry == "" {
|
||||
invalid = append(invalid, emptyTrustedProxyEntry)
|
||||
@@ -58,7 +63,7 @@ func resolveTrustedProxies(raw string) (trusted []string, invalid []string) {
|
||||
return trusted, nil
|
||||
}
|
||||
|
||||
// configureTrustedProxies reads KANDEV_TRUSTED_PROXIES and applies it to the
|
||||
// configureTrustedProxies applies the configured trusted-proxy list to the
|
||||
// router. Unset, empty, or fully invalid lists disable forwarded-header trust
|
||||
// (SetTrustedProxies(nil)); an invalid entry logs a warning naming the bad
|
||||
// value(s). gin trusts all proxies out of the box, so without this call a
|
||||
@@ -66,8 +71,13 @@ func resolveTrustedProxies(raw string) (trusted []string, invalid []string) {
|
||||
// defeat the ClientIP-keyed login rate limiter. It returns the effective
|
||||
// trusted list (nil when nothing is trusted) so callers can apply the same
|
||||
// trust decision to other forwarded headers.
|
||||
func configureTrustedProxies(router *gin.Engine, log *logger.Logger) []string {
|
||||
trusted, invalid := resolveTrustedProxies(os.Getenv(trustedProxiesEnv))
|
||||
func configureTrustedProxies(router *gin.Engine, log *logger.Logger, configured ...[]string) []string {
|
||||
var trusted, invalid []string
|
||||
if len(configured) > 0 {
|
||||
trusted, invalid = resolveTrustedProxyEntries(configured[0])
|
||||
} else {
|
||||
trusted, invalid = resolveTrustedProxies(os.Getenv(trustedProxiesEnv))
|
||||
}
|
||||
if len(invalid) > 0 {
|
||||
log.Warn(trustedProxiesWarningMsg, zap.Strings("invalid_entries", invalid))
|
||||
trusted = nil
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -13,9 +15,37 @@ import (
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
func TestConfigureTrustedProxiesUsesTypedStartupConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte("server:\n trustedProxies:\n - 10.0.0.0/8\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("KANDEV_SERVER_PORT", "")
|
||||
t.Setenv("KANDEV_TRUSTED_PROXIES", "")
|
||||
cfg, err := config.LoadWithPath(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
configureTrustedProxies(router, testLogger(t), cfg.Server.TrustedProxies)
|
||||
router.GET("/", func(c *gin.Context) { c.String(http.StatusOK, c.ClientIP()) })
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "10.0.0.5:1234"
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.7")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if got := rec.Body.String(); got != "203.0.113.7" {
|
||||
t.Fatalf("typed config ClientIP = %q, want 203.0.113.7", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTrustedProxies(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -117,7 +147,7 @@ func TestConfigureTrustedProxiesInvalidEntryWarnsAndFallsBack(t *testing.T) {
|
||||
}
|
||||
warned := false
|
||||
for _, entry := range logs.All() {
|
||||
if !strings.Contains(entry.Message, "KANDEV_TRUSTED_PROXIES") {
|
||||
if !strings.Contains(entry.Message, "trusted-proxy setting") {
|
||||
continue
|
||||
}
|
||||
for _, field := range entry.Context {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// InternalAgentctlStartupConfigEnv carries the resolved agentctl-owned startup
|
||||
// settings from the backend to a managed agentctl process. It is a private
|
||||
// child-process contract, not an operator-facing configuration variable.
|
||||
const InternalAgentctlStartupConfigEnv = "KANDEV_INTERNAL_AGENTCTL_STARTUP_CONFIG"
|
||||
|
||||
// AgentctlStartupConfig is the resolved configuration contract for a managed
|
||||
// agentctl process. Configured distinguishes the backend's resolved contract
|
||||
// from zero-valued structs used by embedded callers and tests.
|
||||
type AgentctlStartupConfig struct {
|
||||
Configured bool `json:"configured"`
|
||||
IdleTimeout time.Duration `json:"idleTimeout"`
|
||||
IdleReaperInterval time.Duration `json:"idleReaperInterval"`
|
||||
NotificationQueueCapacity int `json:"notificationQueueCapacity"`
|
||||
OTLPEndpoint string `json:"otlpEndpoint"`
|
||||
}
|
||||
|
||||
// ManagedAgentctlStartupConfig returns the agentctl settings resolved by the
|
||||
// common startup configuration loader.
|
||||
func (c *Config) ManagedAgentctlStartupConfig() AgentctlStartupConfig {
|
||||
if c == nil {
|
||||
return AgentctlStartupConfig{}
|
||||
}
|
||||
return AgentctlStartupConfig{
|
||||
Configured: true,
|
||||
IdleTimeout: c.Agentctl.IdleTimeout,
|
||||
IdleReaperInterval: c.Agentctl.IdleReaperInterval,
|
||||
NotificationQueueCapacity: c.Agentctl.NotificationQueueCapacity,
|
||||
OTLPEndpoint: c.Observability.OTLPEndpoint,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks the child contract before it crosses a process boundary.
|
||||
func (c AgentctlStartupConfig) Validate() error {
|
||||
if !c.Configured {
|
||||
return fmt.Errorf("agentctl startup configuration is not resolved")
|
||||
}
|
||||
if c.IdleTimeout < 0 {
|
||||
return fmt.Errorf("agentctl idle timeout must be zero or greater")
|
||||
}
|
||||
if c.IdleReaperInterval <= 0 {
|
||||
return fmt.Errorf("agentctl idle reaper interval must be positive")
|
||||
}
|
||||
if c.NotificationQueueCapacity < 1024 || c.NotificationQueueCapacity > 131072 {
|
||||
return fmt.Errorf("agentctl notification queue capacity must be between 1024 and 131072")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeAgentctlStartupConfig serializes a validated child contract for the
|
||||
// private environment handoff used by local, container, and remote launches.
|
||||
func EncodeAgentctlStartupConfig(c AgentctlStartupConfig) (string, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode agentctl startup configuration: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// DecodeAgentctlStartupConfig parses and validates a private child contract.
|
||||
func DecodeAgentctlStartupConfig(raw string) (AgentctlStartupConfig, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return AgentctlStartupConfig{}, fmt.Errorf("agentctl startup configuration is empty")
|
||||
}
|
||||
var c AgentctlStartupConfig
|
||||
if err := json.Unmarshal([]byte(raw), &c); err != nil {
|
||||
return AgentctlStartupConfig{}, fmt.Errorf("decode agentctl startup configuration: %w", err)
|
||||
}
|
||||
if err := c.Validate(); err != nil {
|
||||
return AgentctlStartupConfig{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SettingSource identifies the source that supplied a resolved startup value.
|
||||
type SettingSource string
|
||||
|
||||
const (
|
||||
SourceDefault SettingSource = "default"
|
||||
SourceProfile SettingSource = "profile"
|
||||
SourceConfiguration SettingSource = "configuration"
|
||||
SourceEnvironment SettingSource = "environment"
|
||||
SourceDatabase SettingSource = "database"
|
||||
SourceFlag SettingSource = "flag"
|
||||
)
|
||||
|
||||
// CatalogEntry describes one stable operator-facing startup setting.
|
||||
//
|
||||
// EnvVars is ordered by compatibility priority. The first non-empty variable
|
||||
// wins when more than one alias is present. Internal aliases are included when
|
||||
// a launcher or child process must use the same typed setting.
|
||||
type CatalogEntry struct {
|
||||
Key string
|
||||
EnvVars []string
|
||||
Owner string
|
||||
Default string
|
||||
Sensitive bool
|
||||
Description string
|
||||
}
|
||||
|
||||
// CatalogExclusion records an environment variable that is intentionally not
|
||||
// exposed as a public YAML setting.
|
||||
type CatalogExclusion struct {
|
||||
EnvVar string
|
||||
Class string
|
||||
Reason string
|
||||
}
|
||||
|
||||
var startupCatalog = []CatalogEntry{
|
||||
{Key: "homeDir", EnvVars: []string{"KANDEV_HOME_DIR"}, Owner: "common/config", Default: "~/.kandev"},
|
||||
{Key: "server.host", EnvVars: []string{"KANDEV_SERVER_HOST"}, Owner: "backend", Default: "0.0.0.0"},
|
||||
{Key: "server.port", EnvVars: []string{"KANDEV_SERVER_PORT", "KANDEV_BACKEND_PORT", "KANDEV_PORT"}, Owner: "launcher/backend", Default: "38429"},
|
||||
{Key: "server.readTimeout", EnvVars: []string{"KANDEV_SERVER_READTIMEOUT"}, Owner: "backend", Default: "30"},
|
||||
{Key: "server.writeTimeout", EnvVars: []string{"KANDEV_SERVER_WRITETIMEOUT"}, Owner: "backend", Default: "30"},
|
||||
{Key: "server.webInternalUrl", EnvVars: []string{"KANDEV_WEB_INTERNAL_URL"}, Owner: "launcher/backend", Default: ""},
|
||||
{Key: "server.webTitlePrefix", EnvVars: []string{"KANDEV_WEB_TITLE_PREFIX"}, Owner: "backend", Default: ""},
|
||||
{Key: "server.trustedProxies", EnvVars: []string{"KANDEV_TRUSTED_PROXIES"}, Owner: "backend", Default: ""},
|
||||
{Key: "database.driver", EnvVars: []string{"KANDEV_DATABASE_DRIVER"}, Owner: "database", Default: "sqlite"},
|
||||
{Key: "database.path", EnvVars: []string{"KANDEV_DATABASE_PATH"}, Owner: "launcher/backend", Default: "<home>/data/kandev.db"},
|
||||
{Key: "database.host", EnvVars: []string{"KANDEV_DATABASE_HOST"}, Owner: "database", Default: "localhost"},
|
||||
{Key: "database.port", EnvVars: []string{"KANDEV_DATABASE_PORT"}, Owner: "database", Default: "5432"},
|
||||
{Key: "database.user", EnvVars: []string{"KANDEV_DATABASE_USER"}, Owner: "database", Default: "kandev"},
|
||||
{Key: "database.password", EnvVars: []string{"KANDEV_DATABASE_PASSWORD"}, Owner: "database", Default: "", Sensitive: true},
|
||||
{Key: "database.dbName", EnvVars: []string{"KANDEV_DATABASE_DBNAME"}, Owner: "database", Default: "kandev"},
|
||||
{Key: "database.sslMode", EnvVars: []string{"KANDEV_DATABASE_SSLMODE"}, Owner: "database", Default: "disable"},
|
||||
{Key: "database.maxConns", EnvVars: []string{"KANDEV_DATABASE_MAXCONNS"}, Owner: "database", Default: "25"},
|
||||
{Key: "database.minConns", EnvVars: []string{"KANDEV_DATABASE_MINCONNS"}, Owner: "database", Default: "5"},
|
||||
{Key: "nats.url", EnvVars: []string{"KANDEV_NATS_URL"}, Owner: "events", Default: "", Sensitive: true},
|
||||
{Key: "nats.clusterId", EnvVars: []string{"KANDEV_NATS_CLUSTERID"}, Owner: "events", Default: "kandev-cluster"},
|
||||
{Key: "nats.clientId", EnvVars: []string{"KANDEV_NATS_CLIENTID"}, Owner: "events", Default: "kandev-client"},
|
||||
{Key: "nats.maxReconnects", EnvVars: []string{"KANDEV_NATS_MAXRECONNECTS"}, Owner: "events", Default: "10"},
|
||||
{Key: "events.namespace", EnvVars: []string{"KANDEV_EVENTS_NAMESPACE"}, Owner: "events", Default: ""},
|
||||
{Key: "docker.enabled", EnvVars: []string{"KANDEV_DOCKER_ENABLED"}, Owner: "docker", Default: "true"},
|
||||
{Key: "docker.host", EnvVars: []string{"KANDEV_DOCKER_HOST", "DOCKER_HOST"}, Owner: "docker", Default: "platform socket"},
|
||||
{Key: "docker.apiVersion", EnvVars: []string{"KANDEV_DOCKER_APIVERSION"}, Owner: "docker", Default: ""},
|
||||
{Key: "docker.tlsVerify", EnvVars: []string{"KANDEV_DOCKER_TLSVERIFY"}, Owner: "docker", Default: "false"},
|
||||
{Key: "docker.defaultNetwork", EnvVars: []string{"KANDEV_DOCKER_DEFAULTNETWORK"}, Owner: "docker", Default: "kandev-network"},
|
||||
{Key: "docker.volumeBasePath", EnvVars: []string{"KANDEV_DOCKER_VOLUMEBASEPATH"}, Owner: "docker", Default: "platform default"},
|
||||
{Key: "agent.standaloneHost", EnvVars: []string{"KANDEV_AGENT_STANDALONE_HOST"}, Owner: "agentctl", Default: "localhost"},
|
||||
{Key: "agent.standalonePort", EnvVars: []string{"AGENTCTL_PORT", "KANDEV_AGENT_STANDALONE_PORT"}, Owner: "agentctl", Default: "39429"},
|
||||
{Key: "auth.jwtSecret", EnvVars: []string{"KANDEV_AUTH_JWTSECRET"}, Owner: "auth", Default: "generated", Sensitive: true},
|
||||
{Key: "auth.tokenDuration", EnvVars: []string{"KANDEV_AUTH_TOKENDURATION"}, Owner: "auth", Default: "3600"},
|
||||
{Key: "auth.sessionTTLHours", EnvVars: []string{"KANDEV_AUTH_SESSIONTTLHOURS"}, Owner: "auth", Default: "720"},
|
||||
{Key: "auth.cookieName", EnvVars: []string{"KANDEV_AUTH_COOKIE_NAME"}, Owner: "auth", Default: ""},
|
||||
{Key: "logging.level", EnvVars: []string{"KANDEV_LOG_LEVEL"}, Owner: "logging", Default: "info"},
|
||||
{Key: "logging.format", EnvVars: []string{"KANDEV_LOGGING_FORMAT"}, Owner: "logging", Default: "text"},
|
||||
{Key: "repositoryDiscovery.roots", EnvVars: []string{"KANDEV_REPOSITORYDISCOVERY_ROOTS"}, Owner: "repository discovery", Default: "[]"},
|
||||
{Key: "repositoryDiscovery.maxDepth", EnvVars: []string{"KANDEV_REPOSITORYDISCOVERY_MAXDEPTH"}, Owner: "repository discovery", Default: "5"},
|
||||
{Key: "worktree.enabled", EnvVars: []string{"KANDEV_WORKTREE_ENABLED"}, Owner: "worktree", Default: "true"},
|
||||
{Key: "worktree.defaultBranch", EnvVars: []string{"KANDEV_WORKTREE_DEFAULTBRANCH"}, Owner: "worktree", Default: "main"},
|
||||
{Key: "worktree.cleanupOnRemove", EnvVars: []string{"KANDEV_WORKTREE_CLEANUPONREMOVE"}, Owner: "worktree", Default: "true"},
|
||||
{Key: "worktree.fetchTimeoutSeconds", EnvVars: []string{"KANDEV_WORKTREE_FETCHTIMEOUTSECONDS"}, Owner: "worktree", Default: "60"},
|
||||
{Key: "worktree.pullTimeoutSeconds", EnvVars: []string{"KANDEV_WORKTREE_PULLTIMEOUTSECONDS"}, Owner: "worktree", Default: "60"},
|
||||
{Key: "repoClone.basePath", EnvVars: []string{"KANDEV_REPOCLONE_BASEPATH"}, Owner: "repository clone", Default: "<home>/repos"},
|
||||
{Key: "debug.devMode", EnvVars: []string{"KANDEV_DEBUG_DEV_MODE"}, Owner: "debug", Default: "false"},
|
||||
{Key: "debug.pprofEnabled", EnvVars: []string{"KANDEV_DEBUG_PPROF_ENABLED"}, Owner: "debug", Default: "false"},
|
||||
{Key: "office.jwtSigningKey", EnvVars: []string{"KANDEV_OFFICE_JWTSIGNINGKEY"}, Owner: "office", Default: "random per start", Sensitive: true},
|
||||
{Key: "office.schedulerTickMs", EnvVars: []string{"KANDEV_OFFICE_SCHEDULER_TICK_MS"}, Owner: "office", Default: "5000"},
|
||||
{Key: "githubCredentialBroker.publicBaseUrl", EnvVars: []string{"KANDEV_GITHUB_CREDENTIAL_BROKER_PUBLIC_BASE_URL"}, Owner: "github credential broker", Default: ""},
|
||||
{Key: "tasks.preparationTimeout", EnvVars: []string{"KANDEV_TASK_PREPARATION_TIMEOUT"}, Owner: "task lifecycle", Default: "10m"},
|
||||
{Key: "credentials.file", EnvVars: []string{"KANDEV_CREDENTIALS_FILE"}, Owner: "credentials", Default: ""},
|
||||
{Key: "limits.ghMaxConcurrent", EnvVars: []string{"KANDEV_GH_MAX_CONCURRENT"}, Owner: "subprocess admission", Default: "8"},
|
||||
{Key: "limits.gitMaxConcurrent", EnvVars: []string{"KANDEV_GIT_MAX_CONCURRENT"}, Owner: "subprocess admission", Default: "12"},
|
||||
{Key: "limits.lspMaxConnections", EnvVars: []string{"KANDEV_LSP_MAX_CONNECTIONS"}, Owner: "LSP gateway", Default: "8"},
|
||||
{Key: "messageQueue.maxPerSession", EnvVars: []string{"KANDEV_QUEUE_MAX_PER_SESSION"}, Owner: "message queue", Default: "10"},
|
||||
{Key: "agentctl.idleTimeout", EnvVars: []string{"KANDEV_ACP_IDLE_TIMEOUT"}, Owner: "agentctl", Default: "1h"},
|
||||
{Key: "agentctl.idleReaperInterval", EnvVars: []string{"KANDEV_ACP_IDLE_REAPER_INTERVAL"}, Owner: "agentctl", Default: "1m"},
|
||||
{Key: "agentctl.notificationQueueCapacity", EnvVars: []string{"KANDEV_ACP_NOTIF_QUEUE"}, Owner: "agentctl", Default: "131072"},
|
||||
{Key: "planning.coalesceWindowMs", EnvVars: []string{"KANDEV_PLAN_COALESCE_WINDOW_MS"}, Owner: "planning", Default: "300000"},
|
||||
{Key: "observability.otlpEndpoint", EnvVars: []string{"OTEL_EXPORTER_OTLP_ENDPOINT"}, Owner: "observability", Default: "", Sensitive: true},
|
||||
{Key: "launcher.webPort", EnvVars: []string{"KANDEV_WEB_PORT"}, Owner: "launcher", Default: "automatic"},
|
||||
{Key: "launcher.healthTimeoutMs", EnvVars: []string{"KANDEV_HEALTH_TIMEOUT_MS"}, Owner: "launcher", Default: "45000"},
|
||||
{Key: "launcher.noBrowser", EnvVars: []string{"KANDEV_NO_BROWSER"}, Owner: "launcher", Default: "false"},
|
||||
}
|
||||
|
||||
var startupExclusions = []CatalogExclusion{
|
||||
{EnvVar: InternalConfigFileEnv, Class: "internal wiring", Reason: "launcher-selected configuration file handoff"},
|
||||
{EnvVar: InternalConfigHomeFileEnv, Class: "internal wiring", Reason: "launcher-selected home-file handoff"},
|
||||
{EnvVar: InternalAgentctlStartupConfigEnv, Class: "internal wiring", Reason: "resolved agentctl child configuration handoff"},
|
||||
{EnvVar: "KANDEV_LAUNCHER_PARENT_PID", Class: "internal wiring", Reason: "launcher parent-watchdog handoff"},
|
||||
{EnvVar: "KANDEV_BACKEND_PID_FILE", Class: "internal wiring", Reason: "supervisor-owned process state"},
|
||||
{EnvVar: "KANDEV_DESKTOP_HEALTH_TOKEN", Class: "generated", Reason: "per-launch health authentication token"},
|
||||
{EnvVar: "KANDEV_DESKTOP_NATIVE_NOTIFICATIONS", Class: "internal wiring", Reason: "desktop shell capability handoff"},
|
||||
{EnvVar: "KANDEV_BUNDLE_DIR", Class: "packaging", Reason: "runtime bundle discovery"},
|
||||
{EnvVar: "KANDEV_WEB_DIST_DIR", Class: "packaging", Reason: "embedded web asset override"},
|
||||
{EnvVar: "KANDEV_TASK_ID", Class: "workspace injection", Reason: "task-owned child process context"},
|
||||
{EnvVar: "KANDEV_SESSION_ID", Class: "workspace injection", Reason: "session-owned child process context"},
|
||||
{EnvVar: "KANDEV_WORKSPACE_ID", Class: "workspace injection", Reason: "workspace-owned child process context"},
|
||||
{EnvVar: "KANDEV_E2E_MOCK", Class: "test", Reason: "profile selector and test harness"},
|
||||
{EnvVar: "KANDEV_MOCK_AGENT", Class: "test", Reason: "profile-selected mock provider"},
|
||||
{EnvVar: "KANDEV_MOCK_GITHUB", Class: "test", Reason: "test-only provider replacement"},
|
||||
{EnvVar: "KANDEV_MOCK_GITLAB", Class: "test", Reason: "test-only provider replacement"},
|
||||
{EnvVar: "KANDEV_MOCK_JIRA", Class: "test", Reason: "test-only provider replacement"},
|
||||
{EnvVar: "KANDEV_MOCK_LINEAR", Class: "test", Reason: "test-only provider replacement"},
|
||||
{EnvVar: "KANDEV_FEATURES_OFFICE", Class: "profile", Reason: "runtime feature flag registry"},
|
||||
{EnvVar: "KANDEV_FEATURES_AUTH", Class: "profile", Reason: "runtime feature flag registry"},
|
||||
{EnvVar: "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF", Class: "debug", Reason: "runtime feature flag registry"},
|
||||
{EnvVar: "KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING", Class: "debug", Reason: "runtime feature flag registry"},
|
||||
{EnvVar: "KANDEV_DEBUG_AGENT_MESSAGES", Class: "debug", Reason: "ACP frame diagnostics"},
|
||||
{EnvVar: "KANDEV_DEBUG_ACP_MAX_FILES", Class: "debug", Reason: "ACP debug retention"},
|
||||
{EnvVar: "KANDEV_DEBUG_ACP_RETENTION_HOURS", Class: "debug", Reason: "ACP debug retention"},
|
||||
{EnvVar: "KANDEV_DEBUG_ACP_MAX_FILE_BYTES", Class: "debug", Reason: "ACP debug retention"},
|
||||
{EnvVar: "KANDEV_MCP_LOG_FILE", Class: "debug", Reason: "agentctl MCP debug logging"},
|
||||
{EnvVar: "KANDEV_DEBUG_LOG_DIR", Class: "debug", Reason: "ACP debug logging directory"},
|
||||
{EnvVar: "AGENTCTL_AUTO_APPROVE_PERMISSIONS", Class: "test", Reason: "profile-selected E2E behavior"},
|
||||
}
|
||||
|
||||
// ConfigurationCatalog returns a defensive copy of the stable startup
|
||||
// catalog. Callers may sort or edit the returned slices.
|
||||
func ConfigurationCatalog() []CatalogEntry {
|
||||
out := make([]CatalogEntry, len(startupCatalog))
|
||||
for i, entry := range startupCatalog {
|
||||
out[i] = entry
|
||||
out[i].EnvVars = append([]string(nil), entry.EnvVars...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ConfigurationExclusions returns a defensive copy of the reviewed
|
||||
// environment-variable exclusions.
|
||||
func ConfigurationExclusions() []CatalogExclusion {
|
||||
return append([]CatalogExclusion(nil), startupExclusions...)
|
||||
}
|
||||
|
||||
// CatalogEntryForKey returns the catalog entry for a canonical YAML key.
|
||||
func CatalogEntryForKey(key string) (CatalogEntry, bool) {
|
||||
for _, entry := range startupCatalog {
|
||||
if entry.Key == key {
|
||||
entry.EnvVars = append([]string(nil), entry.EnvVars...)
|
||||
return entry, true
|
||||
}
|
||||
}
|
||||
return CatalogEntry{}, false
|
||||
}
|
||||
|
||||
// ValidateCatalog checks that canonical keys and environment aliases are
|
||||
// unique and that every audited environment variable has an owner.
|
||||
func ValidateCatalog() error {
|
||||
keys := make(map[string]struct{}, len(startupCatalog))
|
||||
envs := make(map[string]string)
|
||||
for _, entry := range startupCatalog {
|
||||
if entry.Key == "" || entry.Owner == "" {
|
||||
return fmt.Errorf("catalog entry has empty key or owner: %#v", entry)
|
||||
}
|
||||
if _, exists := keys[entry.Key]; exists {
|
||||
return fmt.Errorf("duplicate catalog key %q", entry.Key)
|
||||
}
|
||||
keys[entry.Key] = struct{}{}
|
||||
for _, envVar := range entry.EnvVars {
|
||||
if envVar == "" {
|
||||
return fmt.Errorf("catalog key %q has an empty environment alias", entry.Key)
|
||||
}
|
||||
if previous, exists := envs[envVar]; exists {
|
||||
return fmt.Errorf("environment alias %q belongs to both %q and %q", envVar, previous, entry.Key)
|
||||
}
|
||||
envs[envVar] = entry.Key
|
||||
}
|
||||
}
|
||||
for _, exclusion := range startupExclusions {
|
||||
if exclusion.EnvVar == "" || exclusion.Class == "" || strings.TrimSpace(exclusion.Reason) == "" {
|
||||
return fmt.Errorf("incomplete catalog exclusion: %#v", exclusion)
|
||||
}
|
||||
if owner, exists := envs[exclusion.EnvVar]; exists {
|
||||
return fmt.Errorf("environment variable %q is both cataloged as %q and excluded", exclusion.EnvVar, owner)
|
||||
}
|
||||
envs[exclusion.EnvVar] = "excluded"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CatalogEnvironmentVariables returns all cataloged and excluded variables in
|
||||
// stable order. It is used by inventory and documentation completeness tests.
|
||||
func CatalogEnvironmentVariables() []string {
|
||||
seen := make(map[string]struct{})
|
||||
var out []string
|
||||
for _, entry := range startupCatalog {
|
||||
for _, envVar := range entry.EnvVars {
|
||||
if _, ok := seen[envVar]; !ok {
|
||||
seen[envVar] = struct{}{}
|
||||
out = append(out, envVar)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, exclusion := range startupExclusions {
|
||||
if _, ok := seen[exclusion.EnvVar]; !ok {
|
||||
seen[exclusion.EnvVar] = struct{}{}
|
||||
out = append(out, exclusion.EnvVar)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfigurationCatalogIsComplete(t *testing.T) {
|
||||
if err := ValidateCatalog(); err != nil {
|
||||
t.Fatalf("ValidateCatalog: %v", err)
|
||||
}
|
||||
entries := ConfigurationCatalog()
|
||||
if len(entries) < 50 {
|
||||
t.Fatalf("catalog has %d entries, want the stable startup inventory", len(entries))
|
||||
}
|
||||
for _, key := range []string{
|
||||
"server.trustedProxies",
|
||||
"tasks.preparationTimeout",
|
||||
"credentials.file",
|
||||
"limits.ghMaxConcurrent",
|
||||
"messageQueue.maxPerSession",
|
||||
"agentctl.notificationQueueCapacity",
|
||||
"launcher.noBrowser",
|
||||
} {
|
||||
if _, ok := CatalogEntryForKey(key); !ok {
|
||||
t.Errorf("catalog is missing %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationCatalogMatchesAuditedEnvironmentInventory(t *testing.T) {
|
||||
if err := validateCatalogAgainstAuditedInventory(ConfigurationCatalog(), ConfigurationExclusions(), auditedStartupEnvironmentInventory()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationCatalogAuditRejectsUncatalogedEnvironmentVariable(t *testing.T) {
|
||||
err := validateCatalogAgainstAuditedInventory(
|
||||
[]CatalogEntry{{Key: "test.setting", EnvVars: []string{"KANDEV_UNCATALOGED_TEST"}}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("catalog audit accepted an environment variable absent from the audited inventory")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "KANDEV_UNCATALOGED_TEST") {
|
||||
t.Fatalf("catalog audit error = %v, want the uncataloged variable", err)
|
||||
}
|
||||
}
|
||||
|
||||
type auditedStartupEnvironment struct {
|
||||
envVar string
|
||||
class string
|
||||
}
|
||||
|
||||
// auditedStartupEnvironmentInventory is intentionally independent of
|
||||
// startupCatalog. Update it only after auditing every stable environment
|
||||
// consumer and every reviewed exclusion.
|
||||
func auditedStartupEnvironmentInventory() []auditedStartupEnvironment {
|
||||
return []auditedStartupEnvironment{
|
||||
{envVar: "KANDEV_HOME_DIR", class: "catalog"},
|
||||
{envVar: "KANDEV_SERVER_HOST", class: "catalog"},
|
||||
{envVar: "KANDEV_SERVER_PORT", class: "catalog"},
|
||||
{envVar: "KANDEV_BACKEND_PORT", class: "catalog"},
|
||||
{envVar: "KANDEV_PORT", class: "catalog"},
|
||||
{envVar: "KANDEV_SERVER_READTIMEOUT", class: "catalog"},
|
||||
{envVar: "KANDEV_SERVER_WRITETIMEOUT", class: "catalog"},
|
||||
{envVar: "KANDEV_WEB_INTERNAL_URL", class: "catalog"},
|
||||
{envVar: "KANDEV_WEB_TITLE_PREFIX", class: "catalog"},
|
||||
{envVar: "KANDEV_TRUSTED_PROXIES", class: "catalog"},
|
||||
{envVar: "KANDEV_DATABASE_DRIVER", class: "catalog"},
|
||||
{envVar: "KANDEV_DATABASE_PATH", class: "catalog"},
|
||||
{envVar: "KANDEV_DATABASE_HOST", class: "catalog"},
|
||||
{envVar: "KANDEV_DATABASE_PORT", class: "catalog"},
|
||||
{envVar: "KANDEV_DATABASE_USER", class: "catalog"},
|
||||
{envVar: "KANDEV_DATABASE_PASSWORD", class: "catalog"},
|
||||
{envVar: "KANDEV_DATABASE_DBNAME", class: "catalog"},
|
||||
{envVar: "KANDEV_DATABASE_SSLMODE", class: "catalog"},
|
||||
{envVar: "KANDEV_DATABASE_MAXCONNS", class: "catalog"},
|
||||
{envVar: "KANDEV_DATABASE_MINCONNS", class: "catalog"},
|
||||
{envVar: "KANDEV_NATS_URL", class: "catalog"},
|
||||
{envVar: "KANDEV_NATS_CLUSTERID", class: "catalog"},
|
||||
{envVar: "KANDEV_NATS_CLIENTID", class: "catalog"},
|
||||
{envVar: "KANDEV_NATS_MAXRECONNECTS", class: "catalog"},
|
||||
{envVar: "KANDEV_EVENTS_NAMESPACE", class: "catalog"},
|
||||
{envVar: "KANDEV_DOCKER_ENABLED", class: "catalog"},
|
||||
{envVar: "KANDEV_DOCKER_HOST", class: "catalog"},
|
||||
{envVar: "DOCKER_HOST", class: "catalog"},
|
||||
{envVar: "KANDEV_DOCKER_APIVERSION", class: "catalog"},
|
||||
{envVar: "KANDEV_DOCKER_TLSVERIFY", class: "catalog"},
|
||||
{envVar: "KANDEV_DOCKER_DEFAULTNETWORK", class: "catalog"},
|
||||
{envVar: "KANDEV_DOCKER_VOLUMEBASEPATH", class: "catalog"},
|
||||
{envVar: "KANDEV_AGENT_STANDALONE_HOST", class: "catalog"},
|
||||
{envVar: "AGENTCTL_PORT", class: "catalog"},
|
||||
{envVar: "KANDEV_AGENT_STANDALONE_PORT", class: "catalog"},
|
||||
{envVar: "KANDEV_AUTH_JWTSECRET", class: "catalog"},
|
||||
{envVar: "KANDEV_AUTH_TOKENDURATION", class: "catalog"},
|
||||
{envVar: "KANDEV_AUTH_SESSIONTTLHOURS", class: "catalog"},
|
||||
{envVar: "KANDEV_AUTH_COOKIE_NAME", class: "catalog"},
|
||||
{envVar: "KANDEV_LOG_LEVEL", class: "catalog"},
|
||||
{envVar: "KANDEV_LOGGING_FORMAT", class: "catalog"},
|
||||
{envVar: "KANDEV_REPOSITORYDISCOVERY_ROOTS", class: "catalog"},
|
||||
{envVar: "KANDEV_REPOSITORYDISCOVERY_MAXDEPTH", class: "catalog"},
|
||||
{envVar: "KANDEV_WORKTREE_ENABLED", class: "catalog"},
|
||||
{envVar: "KANDEV_WORKTREE_DEFAULTBRANCH", class: "catalog"},
|
||||
{envVar: "KANDEV_WORKTREE_CLEANUPONREMOVE", class: "catalog"},
|
||||
{envVar: "KANDEV_WORKTREE_FETCHTIMEOUTSECONDS", class: "catalog"},
|
||||
{envVar: "KANDEV_WORKTREE_PULLTIMEOUTSECONDS", class: "catalog"},
|
||||
{envVar: "KANDEV_REPOCLONE_BASEPATH", class: "catalog"},
|
||||
{envVar: "KANDEV_DEBUG_DEV_MODE", class: "catalog"},
|
||||
{envVar: "KANDEV_DEBUG_PPROF_ENABLED", class: "catalog"},
|
||||
{envVar: "KANDEV_OFFICE_JWTSIGNINGKEY", class: "catalog"},
|
||||
{envVar: "KANDEV_OFFICE_SCHEDULER_TICK_MS", class: "catalog"},
|
||||
{envVar: "KANDEV_GITHUB_CREDENTIAL_BROKER_PUBLIC_BASE_URL", class: "catalog"},
|
||||
{envVar: "KANDEV_TASK_PREPARATION_TIMEOUT", class: "catalog"},
|
||||
{envVar: "KANDEV_CREDENTIALS_FILE", class: "catalog"},
|
||||
{envVar: "KANDEV_GH_MAX_CONCURRENT", class: "catalog"},
|
||||
{envVar: "KANDEV_GIT_MAX_CONCURRENT", class: "catalog"},
|
||||
{envVar: "KANDEV_LSP_MAX_CONNECTIONS", class: "catalog"},
|
||||
{envVar: "KANDEV_QUEUE_MAX_PER_SESSION", class: "catalog"},
|
||||
{envVar: "KANDEV_ACP_IDLE_TIMEOUT", class: "catalog"},
|
||||
{envVar: "KANDEV_ACP_IDLE_REAPER_INTERVAL", class: "catalog"},
|
||||
{envVar: "KANDEV_ACP_NOTIF_QUEUE", class: "catalog"},
|
||||
{envVar: "KANDEV_PLAN_COALESCE_WINDOW_MS", class: "catalog"},
|
||||
{envVar: "OTEL_EXPORTER_OTLP_ENDPOINT", class: "catalog"},
|
||||
{envVar: "KANDEV_WEB_PORT", class: "catalog"},
|
||||
{envVar: "KANDEV_HEALTH_TIMEOUT_MS", class: "catalog"},
|
||||
{envVar: "KANDEV_NO_BROWSER", class: "catalog"},
|
||||
{envVar: InternalConfigFileEnv, class: "exclusion"},
|
||||
{envVar: InternalConfigHomeFileEnv, class: "exclusion"},
|
||||
{envVar: InternalAgentctlStartupConfigEnv, class: "exclusion"},
|
||||
{envVar: "KANDEV_LAUNCHER_PARENT_PID", class: "exclusion"},
|
||||
{envVar: "KANDEV_BACKEND_PID_FILE", class: "exclusion"},
|
||||
{envVar: "KANDEV_DESKTOP_HEALTH_TOKEN", class: "exclusion"},
|
||||
{envVar: "KANDEV_DESKTOP_NATIVE_NOTIFICATIONS", class: "exclusion"},
|
||||
{envVar: "KANDEV_BUNDLE_DIR", class: "exclusion"},
|
||||
{envVar: "KANDEV_WEB_DIST_DIR", class: "exclusion"},
|
||||
{envVar: "KANDEV_TASK_ID", class: "exclusion"},
|
||||
{envVar: "KANDEV_SESSION_ID", class: "exclusion"},
|
||||
{envVar: "KANDEV_WORKSPACE_ID", class: "exclusion"},
|
||||
{envVar: "KANDEV_E2E_MOCK", class: "exclusion"},
|
||||
{envVar: "KANDEV_MOCK_AGENT", class: "exclusion"},
|
||||
{envVar: "KANDEV_MOCK_GITHUB", class: "exclusion"},
|
||||
{envVar: "KANDEV_MOCK_GITLAB", class: "exclusion"},
|
||||
{envVar: "KANDEV_MOCK_JIRA", class: "exclusion"},
|
||||
{envVar: "KANDEV_MOCK_LINEAR", class: "exclusion"},
|
||||
{envVar: "KANDEV_FEATURES_OFFICE", class: "exclusion"},
|
||||
{envVar: "KANDEV_FEATURES_AUTH", class: "exclusion"},
|
||||
{envVar: "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF", class: "exclusion"},
|
||||
{envVar: "KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING", class: "exclusion"},
|
||||
{envVar: "KANDEV_DEBUG_AGENT_MESSAGES", class: "exclusion"},
|
||||
{envVar: "KANDEV_DEBUG_ACP_MAX_FILES", class: "exclusion"},
|
||||
{envVar: "KANDEV_DEBUG_ACP_RETENTION_HOURS", class: "exclusion"},
|
||||
{envVar: "KANDEV_DEBUG_ACP_MAX_FILE_BYTES", class: "exclusion"},
|
||||
{envVar: "KANDEV_MCP_LOG_FILE", class: "exclusion"},
|
||||
{envVar: "KANDEV_DEBUG_LOG_DIR", class: "exclusion"},
|
||||
{envVar: "AGENTCTL_AUTO_APPROVE_PERMISSIONS", class: "exclusion"},
|
||||
}
|
||||
}
|
||||
|
||||
func validateCatalogAgainstAuditedInventory(entries []CatalogEntry, exclusions []CatalogExclusion, audited []auditedStartupEnvironment) error {
|
||||
tracked := make(map[string]string, len(entries)+len(exclusions))
|
||||
for _, entry := range entries {
|
||||
for _, envVar := range entry.EnvVars {
|
||||
if previous, exists := tracked[envVar]; exists {
|
||||
return fmt.Errorf("environment variable %q is tracked more than once (%s and catalog)", envVar, previous)
|
||||
}
|
||||
tracked[envVar] = "catalog"
|
||||
}
|
||||
}
|
||||
for _, exclusion := range exclusions {
|
||||
if previous, exists := tracked[exclusion.EnvVar]; exists {
|
||||
return fmt.Errorf("environment variable %q is tracked more than once (%s and exclusion)", exclusion.EnvVar, previous)
|
||||
}
|
||||
tracked[exclusion.EnvVar] = "exclusion"
|
||||
}
|
||||
|
||||
want := make(map[string]string, len(audited))
|
||||
for _, item := range audited {
|
||||
if previous, exists := want[item.envVar]; exists {
|
||||
return fmt.Errorf("audited environment variable %q is listed more than once (%s and %s)", item.envVar, previous, item.class)
|
||||
}
|
||||
want[item.envVar] = item.class
|
||||
}
|
||||
for envVar, class := range tracked {
|
||||
if auditedClass, exists := want[envVar]; !exists {
|
||||
return fmt.Errorf("environment variable %q is %s but absent from the audited inventory", envVar, class)
|
||||
} else if auditedClass != class {
|
||||
return fmt.Errorf("environment variable %q is %s, audited as %s", envVar, class, auditedClass)
|
||||
}
|
||||
}
|
||||
for envVar, class := range want {
|
||||
if trackedClass, exists := tracked[envVar]; !exists {
|
||||
return fmt.Errorf("audited environment variable %q (%s) is neither cataloged nor excluded", envVar, class)
|
||||
} else if trackedClass != class {
|
||||
return fmt.Errorf("audited environment variable %q is %s, tracked as %s", envVar, class, trackedClass)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestConfigurationCatalogMarksSecretsAndExclusions(t *testing.T) {
|
||||
for _, key := range []string{"database.password", "auth.jwtSecret", "office.jwtSigningKey"} {
|
||||
entry, ok := CatalogEntryForKey(key)
|
||||
if !ok || !entry.Sensitive {
|
||||
t.Errorf("catalog entry %q is not marked sensitive: %#v", key, entry)
|
||||
}
|
||||
}
|
||||
exclusions := ConfigurationExclusions()
|
||||
foundGenerated := false
|
||||
for _, exclusion := range exclusions {
|
||||
if exclusion.EnvVar == "KANDEV_DESKTOP_HEALTH_TOKEN" && exclusion.Class == "generated" && strings.TrimSpace(exclusion.Reason) != "" {
|
||||
foundGenerated = true
|
||||
}
|
||||
}
|
||||
if !foundGenerated {
|
||||
t.Fatal("generated health token exclusion is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentctlStartupConfigRoundTripsAndRejectsInvalidValues(t *testing.T) {
|
||||
want := AgentctlStartupConfig{
|
||||
Configured: true,
|
||||
IdleTimeout: 2 * time.Hour,
|
||||
IdleReaperInterval: 3 * time.Minute,
|
||||
NotificationQueueCapacity: 4096,
|
||||
OTLPEndpoint: "http://collector:4318",
|
||||
}
|
||||
raw, err := EncodeAgentctlStartupConfig(want)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeAgentctlStartupConfig: %v", err)
|
||||
}
|
||||
got, err := DecodeAgentctlStartupConfig(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeAgentctlStartupConfig: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("decoded contract = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
invalid := want
|
||||
invalid.NotificationQueueCapacity = 1
|
||||
if _, err := EncodeAgentctlStartupConfig(invalid); err == nil {
|
||||
t.Fatal("EncodeAgentctlStartupConfig accepted an invalid queue capacity")
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,14 @@ type Config struct {
|
||||
// live under this root.
|
||||
HomeDir string `mapstructure:"homeDir"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Tasks TasksConfig `mapstructure:"tasks"`
|
||||
Credentials CredentialsConfig `mapstructure:"credentials"`
|
||||
Limits LimitsConfig `mapstructure:"limits"`
|
||||
MessageQueue MessageQueueConfig `mapstructure:"messageQueue"`
|
||||
Agentctl AgentctlConfig `mapstructure:"agentctl"`
|
||||
Planning PlanningConfig `mapstructure:"planning"`
|
||||
Observability ObservabilityConfig `mapstructure:"observability"`
|
||||
Launcher LauncherConfig `mapstructure:"launcher"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
NATS NATSConfig `mapstructure:"nats"`
|
||||
Events EventsConfig `mapstructure:"events"`
|
||||
@@ -45,6 +53,54 @@ type Config struct {
|
||||
Office OfficeConfig `mapstructure:"office"`
|
||||
Features FeaturesConfig `mapstructure:"features"`
|
||||
GitHubCredentialBroker GitHubCredentialBrokerConfig `mapstructure:"githubCredentialBroker"`
|
||||
Source ConfigSource `mapstructure:"-" json:"-"`
|
||||
}
|
||||
|
||||
// TasksConfig contains task lifecycle startup limits.
|
||||
type TasksConfig struct {
|
||||
PreparationTimeout time.Duration `mapstructure:"preparationTimeout"`
|
||||
}
|
||||
|
||||
// CredentialsConfig contains operator-managed credential file settings.
|
||||
type CredentialsConfig struct {
|
||||
File string `mapstructure:"file"`
|
||||
}
|
||||
|
||||
// LimitsConfig contains process and protocol capacity limits.
|
||||
type LimitsConfig struct {
|
||||
GHMaxConcurrent int `mapstructure:"ghMaxConcurrent"`
|
||||
GitMaxConcurrent int `mapstructure:"gitMaxConcurrent"`
|
||||
LSPMaxConnections int `mapstructure:"lspMaxConnections"`
|
||||
}
|
||||
|
||||
// MessageQueueConfig contains prompt queue limits.
|
||||
type MessageQueueConfig struct {
|
||||
MaxPerSession int `mapstructure:"maxPerSession"`
|
||||
}
|
||||
|
||||
// AgentctlConfig contains settings owned by managed agentctl processes.
|
||||
type AgentctlConfig struct {
|
||||
IdleTimeout time.Duration `mapstructure:"idleTimeout"`
|
||||
IdleReaperInterval time.Duration `mapstructure:"idleReaperInterval"`
|
||||
NotificationQueueCapacity int `mapstructure:"notificationQueueCapacity"`
|
||||
}
|
||||
|
||||
// PlanningConfig contains planning service timing settings.
|
||||
type PlanningConfig struct {
|
||||
CoalesceWindowMs int `mapstructure:"coalesceWindowMs"`
|
||||
}
|
||||
|
||||
// ObservabilityConfig contains process-wide tracing settings.
|
||||
type ObservabilityConfig struct {
|
||||
OTLPEndpoint string `mapstructure:"otlpEndpoint"`
|
||||
}
|
||||
|
||||
// LauncherConfig contains settings used by the native launcher. The backend
|
||||
// reads the same values so a selected file remains one source of truth.
|
||||
type LauncherConfig struct {
|
||||
WebPort int `mapstructure:"webPort"`
|
||||
HealthTimeoutMs int `mapstructure:"healthTimeoutMs"`
|
||||
NoBrowser bool `mapstructure:"noBrowser"`
|
||||
}
|
||||
|
||||
// GitHubCredentialBrokerConfig holds the externally reachable service URL
|
||||
@@ -129,6 +185,7 @@ type ServerConfig struct {
|
||||
// Hosts is the YAML-array form of Host, for config files that prefer an
|
||||
// array to a comma-separated string. It is used only when Host is unset.
|
||||
Hosts []string `mapstructure:"hosts"`
|
||||
TrustedProxies []string `mapstructure:"trustedProxies"`
|
||||
Port int `mapstructure:"port"`
|
||||
ReadTimeout int `mapstructure:"readTimeout"` // in seconds
|
||||
WriteTimeout int `mapstructure:"writeTimeout"` // in seconds
|
||||
@@ -356,7 +413,8 @@ type OfficeConfig struct {
|
||||
// When empty, a random key is generated at startup — fine for dev, but
|
||||
// means every restart invalidates outstanding agent tokens. Production
|
||||
// deployments should set a stable value (e.g. via KANDEV_OFFICE_JWTSIGNINGKEY).
|
||||
JWTSigningKey string `mapstructure:"jwtSigningKey"`
|
||||
JWTSigningKey string `mapstructure:"jwtSigningKey"`
|
||||
SchedulerTickMs int `mapstructure:"schedulerTickMs"`
|
||||
}
|
||||
|
||||
// FeaturesConfig is the typed wire/config shape for runtime feature flags.
|
||||
@@ -612,31 +670,36 @@ func defaultDockerVolumePath() string {
|
||||
// Environment variables use the prefix KANDEV_ with snake_case naming.
|
||||
// Config file should be named config.yaml and placed in the current directory or /etc/kandev/.
|
||||
func Load() (*Config, error) {
|
||||
return LoadWithPath("")
|
||||
return LoadWithHome("")
|
||||
}
|
||||
|
||||
// LoadWithHome reads the selected configuration while using homeDir as the
|
||||
// bootstrap directory for home-file discovery. The override affects file
|
||||
// selection only; environment variables still retain their normal precedence
|
||||
// over values read from the selected file.
|
||||
func LoadWithHome(homeDir string) (*Config, error) {
|
||||
if path := strings.TrimSpace(os.Getenv(InternalConfigFileEnv)); path != "" {
|
||||
return loadWithPath(path, homeDir)
|
||||
}
|
||||
return loadWithPath("", homeDir)
|
||||
}
|
||||
|
||||
// LoadWithPath reads configuration from the specified path or default locations.
|
||||
func LoadWithPath(configPath string) (*Config, error) {
|
||||
return loadWithPath(configPath, "")
|
||||
}
|
||||
|
||||
func loadWithPath(configPath, homeDir string) (*Config, error) {
|
||||
v := viper.New()
|
||||
|
||||
// Apply the active runtime profile (prod / dev / e2e) from the
|
||||
// embedded profiles.yaml. This writes env vars onto our own
|
||||
// process so the subsequent AutomaticEnv and the rest of the
|
||||
// codebase's os.Getenv reads see the YAML-declared values.
|
||||
// Vars already set by the launcher / shell / per-spec override
|
||||
// are left alone, giving precedence:
|
||||
//
|
||||
// shell env / launcher env > profiles.yaml > Go zero values
|
||||
//
|
||||
// A parse error here means someone committed a malformed
|
||||
// profiles.yaml; fail loud so CI catches it before a release ships.
|
||||
if _, _, err := profiles.ApplyProfile(); err != nil {
|
||||
return nil, fmt.Errorf("apply profile defaults: %w", err)
|
||||
profileDefaults, err := profiles.EnvironmentDefaults()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read profile defaults: %w", err)
|
||||
}
|
||||
|
||||
// Set defaults next. setDefaults seeds non-feature config
|
||||
// (server, database, logging, …); feature-flag defaults flow
|
||||
// through env via ApplyProfile + AutomaticEnv below.
|
||||
// through EnvironmentDefaults and AutomaticEnv below.
|
||||
setDefaults(v)
|
||||
|
||||
// Seed Viper's features.* keyspace from profiles.yaml so the
|
||||
@@ -649,24 +712,14 @@ func LoadWithPath(configPath string) (*Config, error) {
|
||||
for name, value := range flags {
|
||||
v.SetDefault("features."+name, value == "true")
|
||||
}
|
||||
setProfileDefaults(v, profileDefaults)
|
||||
|
||||
// Configure environment variables
|
||||
v.SetEnvPrefix("KANDEV")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
// Explicit bindings for snake_case env vars (camelCase config keys)
|
||||
// AutomaticEnv does not handle camelCase to SNAKE_CASE conversion,
|
||||
// so we explicitly bind keys where env var naming differs from config key naming.
|
||||
_ = v.BindEnv("agent.standalonePort", "AGENTCTL_PORT", "KANDEV_AGENT_STANDALONE_PORT")
|
||||
_ = v.BindEnv("agent.standaloneHost", "KANDEV_AGENT_STANDALONE_HOST")
|
||||
_ = v.BindEnv("server.webInternalUrl", "KANDEV_WEB_INTERNAL_URL")
|
||||
_ = v.BindEnv("server.webTitlePrefix", "KANDEV_WEB_TITLE_PREFIX")
|
||||
_ = v.BindEnv("homeDir", "KANDEV_HOME_DIR")
|
||||
_ = v.BindEnv("logging.level", "KANDEV_LOG_LEVEL")
|
||||
_ = v.BindEnv("events.namespace", "KANDEV_EVENTS_NAMESPACE")
|
||||
_ = v.BindEnv("debug.devMode", "KANDEV_DEBUG_DEV_MODE")
|
||||
_ = v.BindEnv("debug.pprofEnabled", "KANDEV_DEBUG_PPROF_ENABLED")
|
||||
bindCatalogEnvironment(v)
|
||||
// camelCase key, so AutomaticEnv would expose the undocumented
|
||||
// KANDEV_AUTH_COOKIENAME; the canonical override is KANDEV_AUTH_COOKIE_NAME.
|
||||
_ = v.BindEnv("auth.cookieName", "KANDEV_AUTH_COOKIE_NAME")
|
||||
@@ -679,29 +732,40 @@ func LoadWithPath(configPath string) (*Config, error) {
|
||||
"KANDEV_GITHUB_CREDENTIAL_BROKER_REISSUE_SIGNING_KEY",
|
||||
)
|
||||
|
||||
// Configure config file
|
||||
v.SetConfigName("config")
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
if configPath != "" {
|
||||
v.AddConfigPath(configPath)
|
||||
selection, found, err := selectConfigFile(configPath, homeDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("select configuration file: %w", err)
|
||||
}
|
||||
v.AddConfigPath(".")
|
||||
v.AddConfigPath("/etc/kandev/")
|
||||
|
||||
// Read config file (ignore if not found)
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return nil, fmt.Errorf("error reading config file: %w", err)
|
||||
if found {
|
||||
if err := readSelectedConfig(v, selection); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
yamlKeys := configKeys(v)
|
||||
envSnapshot := environmentSnapshot()
|
||||
|
||||
var cfg Config
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
if err := decodeConfig(v, &cfg); err != nil {
|
||||
if found {
|
||||
return nil, fmt.Errorf("error unmarshaling config file %q: %w", selection.path, err)
|
||||
}
|
||||
return nil, fmt.Errorf("error unmarshaling config: %w", err)
|
||||
}
|
||||
sources := applyStartupDefaultsAndEnvironment(&cfg, yamlKeys, profileDefaults, envSnapshot)
|
||||
warnings := inspectSecretPermissions(selection, v)
|
||||
cfg.Source = buildConfigSource(selection, v, sources, warnings)
|
||||
|
||||
if err := validateStartupSettings(&cfg); err != nil {
|
||||
if found {
|
||||
return nil, fmt.Errorf("config validation failed for %q: %w", selection.path, err)
|
||||
}
|
||||
return nil, fmt.Errorf("config validation failed: %w", err)
|
||||
}
|
||||
|
||||
if err := validate(&cfg); err != nil {
|
||||
if found {
|
||||
return nil, fmt.Errorf("config validation failed for %q: %w", selection.path, err)
|
||||
}
|
||||
return nil, fmt.Errorf("config validation failed: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/profiles"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// InternalConfigFileEnv pins a managed child process to the configuration
|
||||
// file selected by its launcher. It is process wiring, not an operator-facing
|
||||
// configuration variable, and is intentionally excluded from the catalog.
|
||||
const (
|
||||
InternalConfigFileEnv = "KANDEV_INTERNAL_CONFIG_FILE"
|
||||
InternalConfigHomeFileEnv = "KANDEV_INTERNAL_CONFIG_HOME_FILE"
|
||||
windowsOS = "windows"
|
||||
)
|
||||
|
||||
// ConfigSource records configuration provenance without retaining any decoded
|
||||
// secret values.
|
||||
type ConfigSource struct {
|
||||
FilePath string
|
||||
HomeFile bool
|
||||
Values map[string]SettingSource
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// Value returns the source for key. Unknown keys are treated as built-in
|
||||
// defaults so callers can safely use the result in source-reporting APIs.
|
||||
func (s ConfigSource) Value(key string) SettingSource {
|
||||
if source, ok := s.Values[key]; ok {
|
||||
return source
|
||||
}
|
||||
return SourceDefault
|
||||
}
|
||||
|
||||
// SourceFor returns the source for a canonical YAML key.
|
||||
func (c *Config) SourceFor(key string) SettingSource {
|
||||
if c == nil {
|
||||
return SourceDefault
|
||||
}
|
||||
return c.Source.Value(key)
|
||||
}
|
||||
|
||||
type configCandidate struct {
|
||||
path string
|
||||
isHome bool
|
||||
}
|
||||
|
||||
type configSelection struct {
|
||||
configCandidate
|
||||
}
|
||||
|
||||
// yamlOnlyStartupKeys are parsed from config.yaml and resolved from the
|
||||
// compatible environment aliases below. They intentionally do not use Viper's
|
||||
// AutomaticEnv during Unmarshal: legacy invalid environment values must retain
|
||||
// their fallback behavior instead of turning into typed-config startup errors.
|
||||
var yamlOnlyStartupKeys = map[string]struct{}{
|
||||
"server.trustedProxies": {},
|
||||
"tasks.preparationTimeout": {},
|
||||
"credentials.file": {},
|
||||
"limits.ghMaxConcurrent": {},
|
||||
"limits.gitMaxConcurrent": {},
|
||||
"limits.lspMaxConnections": {},
|
||||
"messageQueue.maxPerSession": {},
|
||||
"agentctl.idleTimeout": {},
|
||||
"agentctl.idleReaperInterval": {},
|
||||
"agentctl.notificationQueueCapacity": {},
|
||||
"planning.coalesceWindowMs": {},
|
||||
"office.schedulerTickMs": {},
|
||||
"observability.otlpEndpoint": {},
|
||||
"launcher.webPort": {},
|
||||
"launcher.healthTimeoutMs": {},
|
||||
"launcher.noBrowser": {},
|
||||
}
|
||||
|
||||
func isYAMLOnlyStartupKey(key string) bool {
|
||||
_, ok := yamlOnlyStartupKeys[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func configCandidates(configPath, homeDir string) ([]configCandidate, error) {
|
||||
if configPath != "" {
|
||||
path := explicitConfigFile(configPath)
|
||||
return []configCandidate{{path: path, isHome: isHomeConfigFile(path, homeDir) || os.Getenv(InternalConfigHomeFileEnv) == "1"}}, nil
|
||||
}
|
||||
|
||||
workingDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve config working directory: %w", err)
|
||||
}
|
||||
homeDir = bootstrapHomeDirFor(homeDir)
|
||||
return []configCandidate{
|
||||
{path: filepath.Join(workingDir, "config.yaml")},
|
||||
{path: filepath.Join(homeDir, "config.yaml"), isHome: true},
|
||||
{path: "/etc/kandev/config.yaml"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isHomeConfigFile(path, homeDir string) bool {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
absHome, err := filepath.Abs(bootstrapHomeDirFor(homeDir))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return filepath.Clean(absPath) == filepath.Join(filepath.Clean(absHome), "config.yaml")
|
||||
}
|
||||
|
||||
func explicitConfigFile(path string) string {
|
||||
info, err := os.Stat(path)
|
||||
if err == nil && info.IsDir() {
|
||||
return filepath.Join(path, "config.yaml")
|
||||
}
|
||||
if filepath.Base(path) == "config.yaml" || strings.HasSuffix(strings.ToLower(path), ".yaml") {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(path, "config.yaml")
|
||||
}
|
||||
|
||||
func bootstrapHomeDir() string {
|
||||
if raw := strings.TrimSpace(os.Getenv("KANDEV_HOME_DIR")); raw != "" {
|
||||
return expandTilde(raw)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return kandevHomeSubdir
|
||||
}
|
||||
return filepath.Join(home, kandevHomeSubdir)
|
||||
}
|
||||
|
||||
func bootstrapHomeDirFor(homeDir string) string {
|
||||
if raw := strings.TrimSpace(homeDir); raw != "" {
|
||||
return expandTilde(raw)
|
||||
}
|
||||
return bootstrapHomeDir()
|
||||
}
|
||||
|
||||
func selectConfigFile(configPath, homeDir string) (configSelection, bool, error) {
|
||||
candidates, err := configCandidates(configPath, homeDir)
|
||||
if err != nil {
|
||||
return configSelection{}, false, err
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if _, err := os.Stat(candidate.path); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
// The candidate exists from the operator's point of view, but
|
||||
// cannot be inspected. Select it so the read error names it
|
||||
// instead of silently falling through to a lower-priority file.
|
||||
return configSelection{configCandidate: candidate}, true, nil
|
||||
}
|
||||
return configSelection{configCandidate: candidate}, true, nil
|
||||
}
|
||||
return configSelection{}, false, nil
|
||||
}
|
||||
|
||||
func readSelectedConfig(v *viper.Viper, selection configSelection) error {
|
||||
v.SetConfigFile(selection.path)
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return fmt.Errorf("read configuration file %q: %w", selection.path, err)
|
||||
}
|
||||
if selection.isHome && v.InConfig("homeDir") {
|
||||
return fmt.Errorf("configuration file %q cannot set homeDir because a home configuration file cannot relocate itself", selection.path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bindCatalogEnvironment(v *viper.Viper) {
|
||||
for _, entry := range startupCatalog {
|
||||
if isYAMLOnlyStartupKey(entry.Key) || len(entry.EnvVars) == 0 {
|
||||
continue
|
||||
}
|
||||
args := append([]string{entry.Key}, entry.EnvVars...)
|
||||
_ = v.BindEnv(args...)
|
||||
}
|
||||
}
|
||||
|
||||
func setProfileDefaults(v *viper.Viper, profileDefaults map[string]string) {
|
||||
for _, entry := range startupCatalog {
|
||||
if isYAMLOnlyStartupKey(entry.Key) {
|
||||
continue
|
||||
}
|
||||
for _, envVar := range entry.EnvVars {
|
||||
if value := profileDefaults[envVar]; value != "" {
|
||||
v.SetDefault(entry.Key, value)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func environmentSnapshot() map[string]string {
|
||||
out := make(map[string]string)
|
||||
for _, entry := range startupCatalog {
|
||||
for _, envVar := range entry.EnvVars {
|
||||
if value, ok := os.LookupEnv(envVar); ok {
|
||||
out[envVar] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func nonEmptyEnv(snapshot map[string]string, names ...string) (string, bool) {
|
||||
for _, name := range names {
|
||||
if value, ok := snapshot[name]; ok && strings.TrimSpace(value) != "" {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func envValue(snapshot map[string]string, names ...string) (string, bool) {
|
||||
for _, name := range names {
|
||||
if value, ok := snapshot[name]; ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func applyStartupDefaultsAndEnvironment(
|
||||
cfg *Config,
|
||||
yamlKeys map[string]bool,
|
||||
profileDefaults map[string]string,
|
||||
envSnapshot map[string]string,
|
||||
) map[string]SettingSource {
|
||||
sources := make(map[string]SettingSource)
|
||||
for _, entry := range startupCatalog {
|
||||
sources[entry.Key] = sourceForEntry(entry, yamlKeys, profileDefaults, envSnapshot)
|
||||
}
|
||||
applyStartupDefaults(cfg, yamlKeys, profileDefaults, sources)
|
||||
applyStartupEnvironment(cfg, envSnapshot, sources)
|
||||
return sources
|
||||
}
|
||||
|
||||
func applyStartupDefaults(cfg *Config, yamlKeys map[string]bool, profileDefaults map[string]string, sources map[string]SettingSource) {
|
||||
setDefaultDuration := func(key string, target *time.Duration, builtin time.Duration) {
|
||||
if yamlKeys[key] {
|
||||
return
|
||||
}
|
||||
*target = builtin
|
||||
if entry, ok := CatalogEntryForKey(key); ok {
|
||||
if raw, found := nonEmptyEnv(profileDefaults, entry.EnvVars...); found {
|
||||
if parsed, err := time.ParseDuration(raw); err == nil {
|
||||
*target = parsed
|
||||
sources[key] = SourceProfile
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setDefaultInt := func(key string, target *int, builtin int) {
|
||||
if yamlKeys[key] {
|
||||
return
|
||||
}
|
||||
*target = builtin
|
||||
if entry, ok := CatalogEntryForKey(key); ok {
|
||||
if raw, found := nonEmptyEnv(profileDefaults, entry.EnvVars...); found {
|
||||
if parsed, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil {
|
||||
*target = parsed
|
||||
sources[key] = SourceProfile
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !yamlKeys["server.trustedProxies"] {
|
||||
cfg.Server.TrustedProxies = nil
|
||||
}
|
||||
setDefaultDuration("tasks.preparationTimeout", &cfg.Tasks.PreparationTimeout, 10*time.Minute)
|
||||
if !yamlKeys["credentials.file"] {
|
||||
cfg.Credentials.File = ""
|
||||
}
|
||||
setDefaultInt("limits.ghMaxConcurrent", &cfg.Limits.GHMaxConcurrent, 8)
|
||||
setDefaultInt("limits.gitMaxConcurrent", &cfg.Limits.GitMaxConcurrent, 12)
|
||||
setDefaultInt("limits.lspMaxConnections", &cfg.Limits.LSPMaxConnections, 8)
|
||||
setDefaultInt("messageQueue.maxPerSession", &cfg.MessageQueue.MaxPerSession, 10)
|
||||
setDefaultDuration("agentctl.idleTimeout", &cfg.Agentctl.IdleTimeout, time.Hour)
|
||||
setDefaultDuration("agentctl.idleReaperInterval", &cfg.Agentctl.IdleReaperInterval, time.Minute)
|
||||
setDefaultInt("agentctl.notificationQueueCapacity", &cfg.Agentctl.NotificationQueueCapacity, 131072)
|
||||
setDefaultInt("planning.coalesceWindowMs", &cfg.Planning.CoalesceWindowMs, 300000)
|
||||
setDefaultInt("office.schedulerTickMs", &cfg.Office.SchedulerTickMs, 5000)
|
||||
if !yamlKeys["observability.otlpEndpoint"] {
|
||||
cfg.Observability.OTLPEndpoint = ""
|
||||
}
|
||||
setDefaultInt("launcher.webPort", &cfg.Launcher.WebPort, 0)
|
||||
setDefaultInt("launcher.healthTimeoutMs", &cfg.Launcher.HealthTimeoutMs, launcherHealthTimeoutDefault())
|
||||
if !yamlKeys["launcher.noBrowser"] {
|
||||
cfg.Launcher.NoBrowser = false
|
||||
}
|
||||
}
|
||||
|
||||
func applyStartupEnvironment(cfg *Config, envSnapshot map[string]string, sources map[string]SettingSource) {
|
||||
applyTrustedProxiesEnv(cfg, envSnapshot, sources)
|
||||
applyDurationEnv("tasks.preparationTimeout", &cfg.Tasks.PreparationTimeout, 10*time.Minute, envSnapshot, sources)
|
||||
applyStringEnvAllowEmpty("credentials.file", &cfg.Credentials.File, envSnapshot, sources)
|
||||
applyPositiveIntEnv("limits.ghMaxConcurrent", &cfg.Limits.GHMaxConcurrent, 8, envSnapshot, sources)
|
||||
applyPositiveIntEnv("limits.gitMaxConcurrent", &cfg.Limits.GitMaxConcurrent, 12, envSnapshot, sources)
|
||||
applyPositiveIntEnv("limits.lspMaxConnections", &cfg.Limits.LSPMaxConnections, 8, envSnapshot, sources)
|
||||
applyNonNegativeIntEnv("messageQueue.maxPerSession", &cfg.MessageQueue.MaxPerSession, 10, envSnapshot, sources)
|
||||
applyNonNegativeDurationEnv("agentctl.idleTimeout", &cfg.Agentctl.IdleTimeout, time.Hour, envSnapshot, sources)
|
||||
applyDurationEnv("agentctl.idleReaperInterval", &cfg.Agentctl.IdleReaperInterval, time.Minute, envSnapshot, sources)
|
||||
applyBoundedIntEnv("agentctl.notificationQueueCapacity", &cfg.Agentctl.NotificationQueueCapacity, 131072, 1024, 131072, envSnapshot, sources)
|
||||
applyNonNegativeIntEnv("planning.coalesceWindowMs", &cfg.Planning.CoalesceWindowMs, 300000, envSnapshot, sources)
|
||||
applyPositiveIntEnv("office.schedulerTickMs", &cfg.Office.SchedulerTickMs, 5000, envSnapshot, sources)
|
||||
applyStringEnvAllowEmpty("observability.otlpEndpoint", &cfg.Observability.OTLPEndpoint, envSnapshot, sources)
|
||||
applyBoundedIntEnv("launcher.webPort", &cfg.Launcher.WebPort, 0, 1, 65535, envSnapshot, sources)
|
||||
applyPositiveIntEnv("launcher.healthTimeoutMs", &cfg.Launcher.HealthTimeoutMs, launcherHealthTimeoutDefault(), envSnapshot, sources)
|
||||
applyBoolEnv("launcher.noBrowser", &cfg.Launcher.NoBrowser, false, envSnapshot, sources)
|
||||
}
|
||||
|
||||
func launcherHealthTimeoutDefault() int {
|
||||
if profiles.DetectEnvironment() == profiles.EnvDev || profiles.DetectEnvironment() == profiles.EnvE2E {
|
||||
return 600000
|
||||
}
|
||||
return 45000
|
||||
}
|
||||
|
||||
func sourceForEntry(entry CatalogEntry, yamlKeys map[string]bool, profileDefaults, envSnapshot map[string]string) SettingSource {
|
||||
if _, found := nonEmptyEnv(envSnapshot, entry.EnvVars...); found {
|
||||
return SourceEnvironment
|
||||
}
|
||||
if yamlKeys[entry.Key] {
|
||||
return SourceConfiguration
|
||||
}
|
||||
if _, found := nonEmptyEnv(profileDefaults, entry.EnvVars...); found {
|
||||
return SourceProfile
|
||||
}
|
||||
return SourceDefault
|
||||
}
|
||||
|
||||
func applyTrustedProxiesEnv(cfg *Config, env map[string]string, sources map[string]SettingSource) {
|
||||
entry, ok := CatalogEntryForKey("server.trustedProxies")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw, found := nonEmptyEnv(env, entry.EnvVars...)
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
cfg.Server.TrustedProxies = make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
cfg.Server.TrustedProxies = append(cfg.Server.TrustedProxies, strings.TrimSpace(part))
|
||||
}
|
||||
sources[entry.Key] = SourceEnvironment
|
||||
}
|
||||
|
||||
func applyDurationEnv(key string, target *time.Duration, fallback time.Duration, env map[string]string, sources map[string]SettingSource) {
|
||||
entry, ok := CatalogEntryForKey(key)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw, found := nonEmptyEnv(env, entry.EnvVars...)
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
parsed, err := time.ParseDuration(strings.TrimSpace(raw))
|
||||
if err != nil || parsed <= 0 {
|
||||
*target = fallback
|
||||
sources[key] = SourceDefault
|
||||
return
|
||||
}
|
||||
*target = parsed
|
||||
sources[key] = SourceEnvironment
|
||||
}
|
||||
|
||||
func applyNonNegativeDurationEnv(key string, target *time.Duration, fallback time.Duration, env map[string]string, sources map[string]SettingSource) {
|
||||
entry, ok := CatalogEntryForKey(key)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw, found := nonEmptyEnv(env, entry.EnvVars...)
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
parsed, err := time.ParseDuration(strings.TrimSpace(raw))
|
||||
if err != nil || parsed < 0 {
|
||||
*target = fallback
|
||||
sources[key] = SourceDefault
|
||||
return
|
||||
}
|
||||
*target = parsed
|
||||
sources[key] = SourceEnvironment
|
||||
}
|
||||
|
||||
func applyStringEnvAllowEmpty(key string, target *string, env map[string]string, sources map[string]SettingSource) {
|
||||
entry, ok := CatalogEntryForKey(key)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if raw, found := envValue(env, entry.EnvVars...); found {
|
||||
*target = strings.TrimSpace(raw)
|
||||
sources[key] = SourceEnvironment
|
||||
}
|
||||
}
|
||||
|
||||
func applyPositiveIntEnv(key string, target *int, fallback int, env map[string]string, sources map[string]SettingSource) {
|
||||
applyBoundedIntEnv(key, target, fallback, 1, int(^uint(0)>>1), env, sources)
|
||||
}
|
||||
|
||||
func applyNonNegativeIntEnv(key string, target *int, fallback int, env map[string]string, sources map[string]SettingSource) {
|
||||
applyBoundedIntEnv(key, target, fallback, 0, int(^uint(0)>>1), env, sources)
|
||||
}
|
||||
|
||||
func applyBoundedIntEnv(key string, target *int, fallback, minimum, maximum int, env map[string]string, sources map[string]SettingSource) {
|
||||
entry, ok := CatalogEntryForKey(key)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw, found := nonEmptyEnv(env, entry.EnvVars...)
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || parsed < minimum || parsed > maximum {
|
||||
*target = fallback
|
||||
sources[key] = SourceDefault
|
||||
return
|
||||
}
|
||||
*target = parsed
|
||||
sources[key] = SourceEnvironment
|
||||
}
|
||||
|
||||
func applyBoolEnv(key string, target *bool, fallback bool, env map[string]string, sources map[string]SettingSource) {
|
||||
entry, ok := CatalogEntryForKey(key)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw, found := nonEmptyEnv(env, entry.EnvVars...)
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
*target = fallback
|
||||
sources[key] = SourceDefault
|
||||
return
|
||||
}
|
||||
*target = parsed
|
||||
sources[key] = SourceEnvironment
|
||||
}
|
||||
|
||||
func configKeys(v *viper.Viper) map[string]bool {
|
||||
keys := make(map[string]bool)
|
||||
for _, entry := range startupCatalog {
|
||||
keys[entry.Key] = v.InConfig(entry.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func buildConfigSource(selection configSelection, v *viper.Viper, sources map[string]SettingSource, warnings []string) ConfigSource {
|
||||
if sources == nil {
|
||||
sources = make(map[string]SettingSource)
|
||||
}
|
||||
for _, entry := range startupCatalog {
|
||||
if _, ok := sources[entry.Key]; !ok {
|
||||
if v.InConfig(entry.Key) {
|
||||
sources[entry.Key] = SourceConfiguration
|
||||
} else {
|
||||
sources[entry.Key] = SourceDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
path := ""
|
||||
if selection.path != "" {
|
||||
path = selection.path
|
||||
}
|
||||
return ConfigSource{FilePath: path, HomeFile: selection.isHome, Values: sources, Warnings: warnings}
|
||||
}
|
||||
|
||||
func inspectSecretPermissions(selection configSelection, v *viper.Viper) []string {
|
||||
if selection.path == "" || runtime.GOOS == windowsOS {
|
||||
return nil
|
||||
}
|
||||
info, err := os.Stat(selection.path)
|
||||
if err != nil || info.Mode().Perm()&0o077 == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, entry := range startupCatalog {
|
||||
if entry.Sensitive && v.InConfig(entry.Key) {
|
||||
warning := fmt.Sprintf("configuration file %q contains a secret setting readable by group or other users; use mode 0600", selection.path)
|
||||
log.Print(warning)
|
||||
return []string{warning}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeConfig(v *viper.Viper, cfg *Config) error {
|
||||
return v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
)))
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadWithHomeUsesOverrideForHomeFileDiscovery(t *testing.T) {
|
||||
t.Setenv("KANDEV_HOME_DIR", "")
|
||||
t.Setenv("KANDEV_SERVER_PORT", "")
|
||||
t.Setenv("KANDEV_BACKEND_PORT", "")
|
||||
t.Setenv("KANDEV_PORT", "")
|
||||
workingDir := t.TempDir()
|
||||
homeDir := t.TempDir()
|
||||
t.Chdir(workingDir)
|
||||
configPath := filepath.Join(homeDir, "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte("server:\n port: 40128\n"), 0o600); err != nil {
|
||||
t.Fatalf("write home config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadWithHome(homeDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithHome: %v", err)
|
||||
}
|
||||
if cfg.Source.FilePath != configPath {
|
||||
t.Fatalf("selected config = %q, want %q", cfg.Source.FilePath, configPath)
|
||||
}
|
||||
if !cfg.Source.HomeFile {
|
||||
t.Fatal("selected override config is not marked as a home file")
|
||||
}
|
||||
if cfg.Server.Port != 40128 {
|
||||
t.Fatalf("server.port = %d, want 40128", cfg.Server.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithHomePreservesWorkingDirectoryPriority(t *testing.T) {
|
||||
t.Setenv("KANDEV_HOME_DIR", "")
|
||||
t.Setenv("KANDEV_SERVER_PORT", "")
|
||||
t.Setenv("KANDEV_BACKEND_PORT", "")
|
||||
t.Setenv("KANDEV_PORT", "")
|
||||
workingDir := t.TempDir()
|
||||
homeDir := t.TempDir()
|
||||
t.Chdir(workingDir)
|
||||
workingConfig := filepath.Join(workingDir, "config.yaml")
|
||||
if err := os.WriteFile(workingConfig, []byte("server:\n port: 40129\n"), 0o600); err != nil {
|
||||
t.Fatalf("write working-directory config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(homeDir, "config.yaml"), []byte("server:\n port: 40130\n"), 0o600); err != nil {
|
||||
t.Fatalf("write home config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadWithHome(homeDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithHome: %v", err)
|
||||
}
|
||||
if cfg.Source.FilePath != workingConfig {
|
||||
t.Fatalf("selected config = %q, want %q", cfg.Source.FilePath, workingConfig)
|
||||
}
|
||||
if cfg.Source.HomeFile {
|
||||
t.Fatal("working-directory config was marked as a home file")
|
||||
}
|
||||
if cfg.Server.Port != 40129 {
|
||||
t.Fatalf("server.port = %d, want 40129", cfg.Server.Port)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfigSourceLoadsStableYAMLFields(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configFile := filepath.Join(dir, "config.yaml")
|
||||
contents := `server:
|
||||
trustedProxies:
|
||||
- 10.0.0.0/8
|
||||
tasks:
|
||||
preparationTimeout: 17m
|
||||
credentials:
|
||||
file: /run/secrets/credentials
|
||||
limits:
|
||||
ghMaxConcurrent: 11
|
||||
gitMaxConcurrent: 13
|
||||
lspMaxConnections: 21
|
||||
messageQueue:
|
||||
maxPerSession: 14
|
||||
agentctl:
|
||||
idleTimeout: 2h
|
||||
idleReaperInterval: 3m
|
||||
notificationQueueCapacity: 2048
|
||||
planning:
|
||||
coalesceWindowMs: 2400
|
||||
office:
|
||||
schedulerTickMs: 7000
|
||||
observability:
|
||||
otlpEndpoint: https://otel.example.test/v1/traces
|
||||
launcher:
|
||||
webPort: 37430
|
||||
healthTimeoutMs: 12345
|
||||
noBrowser: true
|
||||
`
|
||||
if err := os.WriteFile(configFile, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadWithPath(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithPath: %v", err)
|
||||
}
|
||||
|
||||
if got := nestedField(t, cfg, "Server", "TrustedProxies"); !reflect.DeepEqual(got.Interface(), []string{"10.0.0.0/8"}) {
|
||||
t.Fatalf("server.trustedProxies = %#v", got.Interface())
|
||||
}
|
||||
if got := nestedField(t, cfg, "Tasks", "PreparationTimeout"); got.Interface() != 17*time.Minute {
|
||||
t.Fatalf("tasks.preparationTimeout = %#v", got.Interface())
|
||||
}
|
||||
if got := nestedField(t, cfg, "Credentials", "File"); got.String() != "/run/secrets/credentials" {
|
||||
t.Fatalf("credentials.file = %q", got.String())
|
||||
}
|
||||
if got := nestedField(t, cfg, "Limits", "GHMaxConcurrent"); got.Int() != 11 {
|
||||
t.Fatalf("limits.ghMaxConcurrent = %d", got.Int())
|
||||
}
|
||||
if got := nestedField(t, cfg, "Limits", "GitMaxConcurrent"); got.Int() != 13 {
|
||||
t.Fatalf("limits.gitMaxConcurrent = %d", got.Int())
|
||||
}
|
||||
if got := nestedField(t, cfg, "Limits", "LSPMaxConnections"); got.Int() != 21 {
|
||||
t.Fatalf("limits.lspMaxConnections = %d", got.Int())
|
||||
}
|
||||
if got := nestedField(t, cfg, "MessageQueue", "MaxPerSession"); got.Int() != 14 {
|
||||
t.Fatalf("messageQueue.maxPerSession = %d", got.Int())
|
||||
}
|
||||
if got := nestedField(t, cfg, "Agentctl", "IdleTimeout"); got.Interface() != 2*time.Hour {
|
||||
t.Fatalf("agentctl.idleTimeout = %#v", got.Interface())
|
||||
}
|
||||
if got := nestedField(t, cfg, "Agentctl", "IdleReaperInterval"); got.Interface() != 3*time.Minute {
|
||||
t.Fatalf("agentctl.idleReaperInterval = %#v", got.Interface())
|
||||
}
|
||||
if got := nestedField(t, cfg, "Agentctl", "NotificationQueueCapacity"); got.Int() != 2048 {
|
||||
t.Fatalf("agentctl.notificationQueueCapacity = %d", got.Int())
|
||||
}
|
||||
if got := nestedField(t, cfg, "Planning", "CoalesceWindowMs"); got.Int() != 2400 {
|
||||
t.Fatalf("planning.coalesceWindowMs = %d", got.Int())
|
||||
}
|
||||
if got := nestedField(t, cfg, "Office", "SchedulerTickMs"); got.Int() != 7000 {
|
||||
t.Fatalf("office.schedulerTickMs = %d", got.Int())
|
||||
}
|
||||
if got := nestedField(t, cfg, "Observability", "OTLPEndpoint"); got.String() != "https://otel.example.test/v1/traces" {
|
||||
t.Fatalf("observability.otlpEndpoint = %q", got.String())
|
||||
}
|
||||
if got := nestedField(t, cfg, "Launcher", "WebPort"); got.Int() != 37430 {
|
||||
t.Fatalf("launcher.webPort = %d", got.Int())
|
||||
}
|
||||
if got := nestedField(t, cfg, "Launcher", "HealthTimeoutMs"); got.Int() != 12345 {
|
||||
t.Fatalf("launcher.healthTimeoutMs = %d", got.Int())
|
||||
}
|
||||
if got := nestedField(t, cfg, "Launcher", "NoBrowser"); !got.Bool() {
|
||||
t.Fatal("launcher.noBrowser = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeConfigDiscovery(t *testing.T) {
|
||||
t.Setenv("KANDEV_SERVER_PORT", "")
|
||||
homeDir := t.TempDir()
|
||||
t.Chdir(t.TempDir())
|
||||
if err := os.WriteFile(filepath.Join(homeDir, "config.yaml"), []byte("server:\n port: 40123\n"), 0o600); err != nil {
|
||||
t.Fatalf("write home config: %v", err)
|
||||
}
|
||||
t.Setenv("KANDEV_HOME_DIR", homeDir)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Port != 40123 {
|
||||
t.Fatalf("server.port = %d, want 40123", cfg.Server.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeConfigCannotRelocateItself(t *testing.T) {
|
||||
t.Setenv("KANDEV_SERVER_PORT", "")
|
||||
homeDir := t.TempDir()
|
||||
t.Chdir(t.TempDir())
|
||||
if err := os.WriteFile(filepath.Join(homeDir, "config.yaml"), []byte("homeDir: /different\n"), 0o600); err != nil {
|
||||
t.Fatalf("write home config: %v", err)
|
||||
}
|
||||
t.Setenv("KANDEV_HOME_DIR", homeDir)
|
||||
|
||||
_, err := Load()
|
||||
if err == nil || !strings.Contains(err.Error(), "homeDir") || !strings.Contains(err.Error(), homeDir) {
|
||||
t.Fatalf("Load error = %v, want a homeDir relocation error naming %s", err, homeDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSourceMetadataReportsSelectedFileAndYAMLValues(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("server:\n port: 40124\n"), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
t.Setenv("KANDEV_SERVER_PORT", "40125")
|
||||
|
||||
cfg, err := LoadWithPath(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithPath: %v", err)
|
||||
}
|
||||
source := reflect.ValueOf(cfg).Elem().FieldByName("Source")
|
||||
if !source.IsValid() {
|
||||
t.Fatal("Config.Source is missing")
|
||||
}
|
||||
file := source.FieldByName("FilePath")
|
||||
if !file.IsValid() || file.String() != filepath.Join(dir, "config.yaml") {
|
||||
t.Fatalf("selected config file = %#v, want %s", file.Interface(), filepath.Join(dir, "config.yaml"))
|
||||
}
|
||||
values := source.FieldByName("Values")
|
||||
if !values.IsValid() {
|
||||
t.Fatal("Config.Source.Values is missing")
|
||||
}
|
||||
got := values.MapIndex(reflect.ValueOf("server.port"))
|
||||
if !got.IsValid() || got.String() != "environment" {
|
||||
t.Fatalf("server.port source = %#v, want environment", got.Interface())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPrecedenceEnvironmentOverYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("messageQueue:\n maxPerSession: 4\n"), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
t.Setenv("KANDEV_QUEUE_MAX_PER_SESSION", "9")
|
||||
t.Setenv("KANDEV_SERVER_PORT", "")
|
||||
|
||||
cfg, err := LoadWithPath(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithPath: %v", err)
|
||||
}
|
||||
if cfg.MessageQueue.MaxPerSession != 9 {
|
||||
t.Fatalf("messageQueue.maxPerSession = %d, want 9", cfg.MessageQueue.MaxPerSession)
|
||||
}
|
||||
if got := cfg.SourceFor("messageQueue.maxPerSession"); got != SourceEnvironment {
|
||||
t.Fatalf("messageQueue.maxPerSession source = %q, want %q", got, SourceEnvironment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyCredentialsEnvironmentClearsYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("credentials:\n file: /run/secrets/credentials\n"), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
t.Setenv("KANDEV_CREDENTIALS_FILE", "")
|
||||
|
||||
cfg, err := LoadWithPath(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithPath: %v", err)
|
||||
}
|
||||
if cfg.Credentials.File != "" {
|
||||
t.Fatalf("credentials.file = %q, want empty environment override", cfg.Credentials.File)
|
||||
}
|
||||
if got := cfg.SourceFor("credentials.file"); got != SourceEnvironment {
|
||||
t.Fatalf("credentials.file source = %q, want environment", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryDiscoveryRootsEnvironmentUsesCommaSeparatedValues(t *testing.T) {
|
||||
t.Setenv("KANDEV_REPOSITORYDISCOVERY_ROOTS", "/one,/two")
|
||||
|
||||
cfg, err := LoadWithPath(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithPath: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.RepositoryDiscovery.Roots, []string{"/one", "/two"}) {
|
||||
t.Fatalf("repositoryDiscovery.roots = %#v, want comma-separated paths", cfg.RepositoryDiscovery.Roots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidYAMLValueNamesSelectedFileAndKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configFile := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(configFile, []byte("tasks:\n preparationTimeout: not-a-duration\n"), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
t.Setenv("KANDEV_SERVER_PORT", "")
|
||||
|
||||
_, err := LoadWithPath(dir)
|
||||
if err == nil || !strings.Contains(err.Error(), configFile) || !strings.Contains(err.Error(), "preparationTimeout") {
|
||||
t.Fatalf("Load error = %v, want selected file and preparationTimeout", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretConfigPermissionsWarningOmitsValue(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix permission warning is not applicable")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
configFile := filepath.Join(dir, "config.yaml")
|
||||
secret := "do-not-log-this-secret"
|
||||
if err := os.WriteFile(configFile, []byte("auth:\n jwtSecret: "+secret+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
t.Setenv("KANDEV_SERVER_PORT", "")
|
||||
|
||||
cfg, err := LoadWithPath(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithPath: %v", err)
|
||||
}
|
||||
if len(cfg.Source.Warnings) != 1 || !strings.Contains(cfg.Source.Warnings[0], configFile) {
|
||||
t.Fatalf("warnings = %#v, want one warning naming %s", cfg.Source.Warnings, configFile)
|
||||
}
|
||||
if strings.Contains(cfg.Source.Warnings[0], secret) {
|
||||
t.Fatalf("warning exposed secret: %q", cfg.Source.Warnings[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidYAMLRangeNamesSettingAndFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configFile := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(configFile, []byte("limits:\n ghMaxConcurrent: 0\n"), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
t.Setenv("KANDEV_SERVER_PORT", "")
|
||||
|
||||
_, err := LoadWithPath(dir)
|
||||
if err == nil || !strings.Contains(err.Error(), configFile) || !strings.Contains(err.Error(), "ghMaxConcurrent") {
|
||||
t.Fatalf("Load error = %v, want selected file and ghMaxConcurrent", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeConfigDiscoveryOrderUsesOneFirstExistingFile(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
homeDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(workingDir, "config.yaml"), []byte("server:\n port: 40126\n"), 0o600); err != nil {
|
||||
t.Fatalf("write working-directory config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(homeDir, "config.yaml"), []byte("server:\n port: 40127\n"), 0o600); err != nil {
|
||||
t.Fatalf("write home config: %v", err)
|
||||
}
|
||||
t.Setenv("KANDEV_HOME_DIR", homeDir)
|
||||
t.Setenv("KANDEV_SERVER_PORT", "")
|
||||
t.Chdir(workingDir)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Port != 40126 || cfg.Source.FilePath != filepath.Join(workingDir, "config.yaml") {
|
||||
t.Fatalf("selected config = %q, port = %d; want working-directory file and port 40126", cfg.Source.FilePath, cfg.Server.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeConfigInvalidFirstDoesNotFallThrough(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
homeDir := t.TempDir()
|
||||
configFile := filepath.Join(workingDir, "config.yaml")
|
||||
if err := os.WriteFile(configFile, []byte("limits:\n ghMaxConcurrent: 0\n"), 0o600); err != nil {
|
||||
t.Fatalf("write invalid config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(homeDir, "config.yaml"), []byte("limits:\n ghMaxConcurrent: 22\n"), 0o600); err != nil {
|
||||
t.Fatalf("write home config: %v", err)
|
||||
}
|
||||
t.Setenv("KANDEV_HOME_DIR", homeDir)
|
||||
t.Setenv("KANDEV_SERVER_PORT", "")
|
||||
t.Chdir(workingDir)
|
||||
|
||||
_, err := Load()
|
||||
if err == nil || !strings.Contains(err.Error(), configFile) {
|
||||
t.Fatalf("Load error = %v, want invalid first file and no fallback", err)
|
||||
}
|
||||
}
|
||||
|
||||
func nestedField(t *testing.T, value any, names ...string) reflect.Value {
|
||||
t.Helper()
|
||||
current := reflect.ValueOf(value)
|
||||
if current.Kind() == reflect.Pointer {
|
||||
current = current.Elem()
|
||||
}
|
||||
for _, name := range names {
|
||||
if current.Kind() != reflect.Struct {
|
||||
t.Fatalf("cannot find %s in %s", name, current.Type())
|
||||
}
|
||||
current = current.FieldByName(name)
|
||||
if !current.IsValid() {
|
||||
t.Fatalf("configuration field %s is missing", strings.Join(names, "."))
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func TestHomeConfigDefaultDotDirectory(t *testing.T) {
|
||||
userHome := t.TempDir()
|
||||
defaultHome := filepath.Join(userHome, ".kandev")
|
||||
if err := os.MkdirAll(defaultHome, 0o700); err != nil {
|
||||
t.Fatalf("create default home: %v", err)
|
||||
}
|
||||
configFile := filepath.Join(defaultHome, "config.yaml")
|
||||
if err := os.WriteFile(configFile, []byte("server:\n port: 40128\n"), 0o600); err != nil {
|
||||
t.Fatalf("write default home config: %v", err)
|
||||
}
|
||||
t.Setenv("HOME", userHome)
|
||||
t.Setenv("KANDEV_HOME_DIR", "")
|
||||
t.Setenv("KANDEV_SERVER_PORT", "")
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Port != 40128 || cfg.Source.FilePath != configFile {
|
||||
t.Fatalf("selected config = %q, port = %d; want %s and port 40128", cfg.Source.FilePath, cfg.Server.Port, configFile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validateStartupSettings(cfg *Config) error {
|
||||
errs := validateTrustedProxies(cfg)
|
||||
errs = append(errs, configuredValidation(cfg, "tasks.preparationTimeout", cfg.Tasks.PreparationTimeout <= 0, "tasks.preparationTimeout must be positive")...)
|
||||
errs = append(errs, configuredValidation(cfg, "limits.ghMaxConcurrent", cfg.Limits.GHMaxConcurrent <= 0, "limits.ghMaxConcurrent must be positive")...)
|
||||
errs = append(errs, configuredValidation(cfg, "limits.gitMaxConcurrent", cfg.Limits.GitMaxConcurrent <= 0, "limits.gitMaxConcurrent must be positive")...)
|
||||
errs = append(errs, configuredValidation(cfg, "limits.lspMaxConnections", cfg.Limits.LSPMaxConnections <= 0, "limits.lspMaxConnections must be positive")...)
|
||||
errs = append(errs, configuredValidation(cfg, "messageQueue.maxPerSession", cfg.MessageQueue.MaxPerSession < 0, "messageQueue.maxPerSession must be zero or greater")...)
|
||||
errs = append(errs, configuredValidation(cfg, "agentctl.idleTimeout", cfg.Agentctl.IdleTimeout < 0, "agentctl.idleTimeout must be zero or greater")...)
|
||||
errs = append(errs, configuredValidation(cfg, "agentctl.idleReaperInterval", cfg.Agentctl.IdleReaperInterval <= 0, "agentctl.idleReaperInterval must be positive")...)
|
||||
errs = append(errs, configuredValidation(cfg, "agentctl.notificationQueueCapacity", cfg.Agentctl.NotificationQueueCapacity < 1024 || cfg.Agentctl.NotificationQueueCapacity > 131072, "agentctl.notificationQueueCapacity must be between 1024 and 131072")...)
|
||||
errs = append(errs, configuredValidation(cfg, "planning.coalesceWindowMs", cfg.Planning.CoalesceWindowMs < 0, "planning.coalesceWindowMs must be zero or greater")...)
|
||||
errs = append(errs, configuredValidation(cfg, "office.schedulerTickMs", cfg.Office.SchedulerTickMs <= 0, "office.schedulerTickMs must be positive")...)
|
||||
errs = append(errs, configuredValidation(cfg, "launcher.webPort", cfg.Launcher.WebPort < 1 || cfg.Launcher.WebPort > 65535, "launcher.webPort must be between 1 and 65535")...)
|
||||
errs = append(errs, configuredValidation(cfg, "launcher.healthTimeoutMs", cfg.Launcher.HealthTimeoutMs <= 0, "launcher.healthTimeoutMs must be positive")...)
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("%s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func configuredValidation(cfg *Config, key string, invalid bool, message string) []string {
|
||||
if cfg.SourceFor(key) == SourceConfiguration && invalid {
|
||||
return []string{message}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTrustedProxies(cfg *Config) []string {
|
||||
if cfg.SourceFor("server.trustedProxies") != SourceConfiguration {
|
||||
return nil
|
||||
}
|
||||
var errs []string
|
||||
for _, raw := range cfg.Server.TrustedProxies {
|
||||
value := strings.TrimSpace(raw)
|
||||
if net.ParseIP(value) != nil {
|
||||
continue
|
||||
}
|
||||
if _, _, err := net.ParseCIDR(value); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("server.trustedProxies contains invalid IP or CIDR %q", raw))
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package constants
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -38,13 +37,25 @@ const (
|
||||
|
||||
var (
|
||||
// SetupScriptTimeout is the maximum time to wait for a setup script to complete.
|
||||
SetupScriptTimeout = resolveSetupScriptTimeout(os.Getenv("KANDEV_TASK_PREPARATION_TIMEOUT"))
|
||||
SetupScriptTimeout = defaultSetupScriptTimeout
|
||||
|
||||
// AgentLaunchTimeout is the maximum time to wait for agent launch,
|
||||
// including worktree creation and setup script execution.
|
||||
AgentLaunchTimeout = SetupScriptTimeout + setupLaunchAllowance
|
||||
)
|
||||
|
||||
// ApplyPreparationTimeout applies the resolved typed startup setting to the
|
||||
// process-wide launch budgets. It is called once after config.Load, before any
|
||||
// task or agent services are constructed. Keeping the default here preserves
|
||||
// callers that use the constants package without a backend startup sequence.
|
||||
func ApplyPreparationTimeout(timeout time.Duration) {
|
||||
if timeout <= 0 || timeout > maxSetupScriptTimeout {
|
||||
timeout = defaultSetupScriptTimeout
|
||||
}
|
||||
SetupScriptTimeout = timeout
|
||||
AgentLaunchTimeout = timeout + setupLaunchAllowance
|
||||
}
|
||||
|
||||
func resolveSetupScriptTimeout(value string) time.Duration {
|
||||
if value == "" {
|
||||
return defaultSetupScriptTimeout
|
||||
|
||||
@@ -60,3 +60,20 @@ func TestResolveSetupScriptTimeoutRejectsLaunchBudgetOverflow(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPreparationTimeoutUsesTypedStartupValue(t *testing.T) {
|
||||
previousSetup := SetupScriptTimeout
|
||||
previousLaunch := AgentLaunchTimeout
|
||||
t.Cleanup(func() {
|
||||
SetupScriptTimeout = previousSetup
|
||||
AgentLaunchTimeout = previousLaunch
|
||||
})
|
||||
|
||||
ApplyPreparationTimeout(17 * time.Minute)
|
||||
if SetupScriptTimeout != 17*time.Minute {
|
||||
t.Fatalf("SetupScriptTimeout = %s, want 17m", SetupScriptTimeout)
|
||||
}
|
||||
if AgentLaunchTimeout != 22*time.Minute {
|
||||
t.Fatalf("AgentLaunchTimeout = %s, want 22m", AgentLaunchTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +282,16 @@ func (a *classAdmission) setCapForTest(newCap int) func() {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *classAdmission) setCap(newCap int) {
|
||||
a.mu.Lock()
|
||||
a.cap = newCap
|
||||
if newCap > 0 {
|
||||
a.dispatchLocked()
|
||||
}
|
||||
a.mu.Unlock()
|
||||
a.owner.publishCap(newCap)
|
||||
}
|
||||
|
||||
func (a *classAdmission) capacity() int {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
@@ -44,10 +44,23 @@ var (
|
||||
// subproc_acquire_total, subproc_acquire_wait_millis_total). Unit
|
||||
// tests that swap the pool via SetCapForTest reuse the same names
|
||||
// so the published gauges stay coherent across cap changes.
|
||||
ghThrottle = NewNamedThrottle("gh", resolveCap(ghMaxConcurrentEnv, defaultGHMaxConcurrent))
|
||||
gitThrottle = NewNamedClassThrottle("git", resolveCap(gitMaxConcurrentEnv, defaultGitMaxConcurrent))
|
||||
ghThrottle = NewNamedThrottle("gh", defaultGHMaxConcurrent)
|
||||
gitThrottle = NewNamedClassThrottle("git", defaultGitMaxConcurrent)
|
||||
)
|
||||
|
||||
// ConfigureCaps applies the typed startup capacities to the process-wide
|
||||
// throttles. Invalid values are reduced to the existing safe defaults.
|
||||
func ConfigureCaps(ghMax, gitMax int) {
|
||||
if ghMax <= 0 {
|
||||
ghMax = defaultGHMaxConcurrent
|
||||
}
|
||||
if gitMax <= 0 {
|
||||
gitMax = defaultGitMaxConcurrent
|
||||
}
|
||||
ghThrottle.SetCap(ghMax)
|
||||
gitThrottle.SetCap(gitMax)
|
||||
}
|
||||
|
||||
// GH returns the process-wide throttle gating gh subprocess execs.
|
||||
// All gh callers across the codebase share this single semaphore so the
|
||||
// total host fork pressure stays bounded regardless of caller count.
|
||||
|
||||
@@ -67,6 +67,23 @@ func TestResolveGitMaxConcurrent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureCapsUsesTypedStartupValues(t *testing.T) {
|
||||
previousGH := GH().SetCapForTest(defaultGHMaxConcurrent)
|
||||
previousGit := Git().SetCapForTest(defaultGitMaxConcurrent)
|
||||
t.Cleanup(func() {
|
||||
previousGH()
|
||||
previousGit()
|
||||
})
|
||||
|
||||
ConfigureCaps(3, 4)
|
||||
if got := GH().Capacity(); got != 3 {
|
||||
t.Fatalf("GH capacity = %d, want 3", got)
|
||||
}
|
||||
if got := GitCapacity(); got != 4 {
|
||||
t.Fatalf("Git capacity = %d, want 4", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGHGitAreDistinctThrottles verifies the two singletons aren't
|
||||
// accidentally aliased. A regression where both helpers return the same
|
||||
// pool would let a gh storm starve git ops (or vice-versa) — exactly
|
||||
|
||||
@@ -181,6 +181,37 @@ func (t *Throttle) currentSem() chan struct{} {
|
||||
return t.sem
|
||||
}
|
||||
|
||||
// Capacity reports the current admission capacity. A non-positive value means
|
||||
// throttling is disabled.
|
||||
func (t *Throttle) Capacity() int {
|
||||
if t.admission != nil {
|
||||
return t.admission.capacity()
|
||||
}
|
||||
sem := t.currentSem()
|
||||
if sem == nil {
|
||||
return 0
|
||||
}
|
||||
return cap(sem)
|
||||
}
|
||||
|
||||
// SetCap applies a process startup capacity. Unlike SetCapForTest it does not
|
||||
// return a restore function because startup configuration is immutable for the
|
||||
// lifetime of the backend process.
|
||||
func (t *Throttle) SetCap(newCap int) {
|
||||
if t.admission != nil {
|
||||
t.admission.setCap(newCap)
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
if newCap <= 0 {
|
||||
t.sem = nil
|
||||
} else {
|
||||
t.sem = make(chan struct{}, newCap)
|
||||
}
|
||||
t.mu.Unlock()
|
||||
t.publishCap(newCap)
|
||||
}
|
||||
|
||||
// SetCapForTest replaces the slot pool with one of the given capacity and
|
||||
// returns a restore function. Test-only — production code MUST NOT call
|
||||
// this. Use cap=0 to disable throttling for a test.
|
||||
|
||||
@@ -23,3 +23,10 @@ func TestLSPCapacityLimiterFallsBackToDefaultForInvalidEnv(t *testing.T) {
|
||||
t.Fatalf("max = %d, want %d", limiter.max, defaultLSPMaxConnections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLSPHandlerUsesTypedCapacity(t *testing.T) {
|
||||
handler := NewLSPHandler(nil, nil, testLogger(), 21)
|
||||
if handler.capacity.max != 21 {
|
||||
t.Fatalf("LSP capacity = %d, want 21", handler.capacity.max)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,11 +69,15 @@ var lspUpgrader = gorillaws.Upgrader{
|
||||
}
|
||||
|
||||
// NewLSPHandler creates a new LSPHandler.
|
||||
func NewLSPHandler(lifecycleMgr *lifecycle.Manager, userService LSPUserService, log *logger.Logger) *LSPHandler {
|
||||
func NewLSPHandler(lifecycleMgr *lifecycle.Manager, userService LSPUserService, log *logger.Logger, configuredMax ...int) *LSPHandler {
|
||||
capacity := newLSPCapacityLimiterFromEnv()
|
||||
if len(configuredMax) > 0 {
|
||||
capacity = newLSPCapacityLimiter(configuredMax[0])
|
||||
}
|
||||
return &LSPHandler{
|
||||
lifecycleMgr: lifecycleMgr,
|
||||
userService: userService,
|
||||
capacity: newLSPCapacityLimiterFromEnv(),
|
||||
capacity: capacity,
|
||||
logger: log.WithFields(zap.String("component", "lsp_handler")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,8 @@ func (g *Gateway) SetLifecycleManager(lifecycleMgr *lifecycle.Manager, userServi
|
||||
}
|
||||
|
||||
// SetLSPHandler enables the LSP WebSocket handler.
|
||||
func (g *Gateway) SetLSPHandler(lifecycleMgr *lifecycle.Manager, userService LSPUserService) {
|
||||
g.LSPHandler = NewLSPHandler(lifecycleMgr, userService, g.logger)
|
||||
func (g *Gateway) SetLSPHandler(lifecycleMgr *lifecycle.Manager, userService LSPUserService, configuredMax ...int) {
|
||||
g.LSPHandler = NewLSPHandler(lifecycleMgr, userService, g.logger, configuredMax...)
|
||||
}
|
||||
|
||||
// SetVscodeProxy enables the VS Code reverse proxy handler.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package launcher
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/profiles"
|
||||
)
|
||||
|
||||
func loadBootstrapConfig() (*config.Config, error) {
|
||||
return loadBootstrapConfigWithHome("")
|
||||
}
|
||||
|
||||
func loadBootstrapConfigWithHome(homeDir string) (*config.Config, error) {
|
||||
cfg, err := config.LoadWithHome(homeDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load startup configuration: %w", err)
|
||||
}
|
||||
if _, _, err := profiles.ApplyProfile(); err != nil {
|
||||
return nil, fmt.Errorf("apply profile defaults: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func loadServiceBootstrapConfig(args serviceArgs) (*config.Config, error) {
|
||||
return loadBootstrapConfigWithHome(serviceBootstrapHomeDir(args))
|
||||
}
|
||||
|
||||
func serviceBootstrapHomeDir(args serviceArgs) string {
|
||||
if strings.TrimSpace(args.HomeDir) != "" {
|
||||
return args.HomeDir
|
||||
}
|
||||
if raw := strings.TrimSpace(os.Getenv("KANDEV_HOME_DIR")); raw != "" {
|
||||
return expandHome(raw)
|
||||
}
|
||||
if args.System {
|
||||
return defaultSystemServiceHomeDir
|
||||
}
|
||||
return resolveHomeDir()
|
||||
}
|
||||
|
||||
func configSourceIsExplicit(cfg *config.Config, key string) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
source := cfg.SourceFor(key)
|
||||
return source == config.SourceConfiguration || source == config.SourceEnvironment || source == config.SourceProfile
|
||||
}
|
||||
|
||||
func resolveHomeDirForConfig(cfg *config.Config) string {
|
||||
if cfg == nil {
|
||||
return resolveHomeDir()
|
||||
}
|
||||
return cfg.ResolvedHomeDir()
|
||||
}
|
||||
|
||||
func resolveDataDirForConfig(cfg *config.Config) string {
|
||||
if cfg == nil {
|
||||
return resolveDataDir()
|
||||
}
|
||||
return cfg.ResolvedDataDir()
|
||||
}
|
||||
|
||||
func resolveDatabasePathForConfig(cfg *config.Config) string {
|
||||
if cfg == nil {
|
||||
return resolveDatabasePath()
|
||||
}
|
||||
if path := strings.TrimSpace(cfg.Database.Path); path != "" {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(resolveDataDirForConfig(cfg), "kandev.db")
|
||||
}
|
||||
|
||||
func ensureDataDirForConfig(cfg *config.Config) error {
|
||||
dir := resolveDataDirForConfig(cfg)
|
||||
if err := rejectSymlinkComponents(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func configFileForChild(cfg *config.Config) string {
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.Source.FilePath
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package launcher
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
)
|
||||
|
||||
func TestLauncherBootstrapUsesYAMLValues(t *testing.T) {
|
||||
clearLauncherConfigurationEnvironment(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
dbPath := filepath.Join(dir, "state", "custom.db")
|
||||
configPath := filepath.Join(dir, "config.yaml")
|
||||
writeLauncherConfig(t, configPath, "homeDir: /srv/kandev\nserver:\n host: 127.0.0.1\n port: 4321\ndatabase:\n path: "+dbPath+"\nlogging:\n level: error\nlauncher:\n webPort: 4322\n healthTimeoutMs: 12345\n noBrowser: true\n")
|
||||
|
||||
cfg, err := loadBootstrapConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Source.FilePath != configPath {
|
||||
t.Fatalf("selected config = %q, want %q", cfg.Source.FilePath, configPath)
|
||||
}
|
||||
if got := resolveHomeDirForConfig(cfg); got != "/srv/kandev" {
|
||||
t.Fatalf("home directory = %q, want /srv/kandev", got)
|
||||
}
|
||||
if got := resolveDatabasePathForConfig(cfg); got != dbPath {
|
||||
t.Fatalf("database path = %q, want %q", got, dbPath)
|
||||
}
|
||||
if got, err := resolvePortsForConfig(Options{}, cfg); err != nil || got != 4321 {
|
||||
t.Fatalf("backend port = %d, %v, want 4321", got, err)
|
||||
}
|
||||
if got, err := resolveWebPortForConfig(Options{}, cfg); err != nil || got != 4322 {
|
||||
t.Fatalf("web port = %d, %v, want 4322", got, err)
|
||||
}
|
||||
if got := resolveLogLevelForConfig(Options{}, cfg); got != "error" {
|
||||
t.Fatalf("log level = %q, want error", got)
|
||||
}
|
||||
if got := healthTimeoutForConfig(45000, cfg); got != 12345*time.Millisecond {
|
||||
t.Fatalf("health timeout = %s, want 12.345s", got)
|
||||
}
|
||||
if shouldOpenBrowser(Options{}, cfg) {
|
||||
t.Fatal("YAML launcher.noBrowser did not suppress the browser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherBootstrapEnvironmentOverridesYAMLAndFlagsOverrideEnvironment(t *testing.T) {
|
||||
clearLauncherConfigurationEnvironment(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
writeLauncherConfig(t, filepath.Join(dir, "config.yaml"), "server:\n port: 4321\nlogging:\n level: error\nlauncher:\n webPort: 4322\n healthTimeoutMs: 12345\n noBrowser: false\n")
|
||||
t.Setenv("KANDEV_BACKEND_PORT", "4323")
|
||||
t.Setenv("KANDEV_PORT", "4324")
|
||||
t.Setenv("KANDEV_WEB_PORT", "4325")
|
||||
t.Setenv("KANDEV_LOG_LEVEL", "warn")
|
||||
t.Setenv("KANDEV_HEALTH_TIMEOUT_MS", "23456")
|
||||
t.Setenv("KANDEV_NO_BROWSER", "true")
|
||||
|
||||
cfg, err := loadBootstrapConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := resolvePortsForConfig(Options{}, cfg); err != nil || got != 4323 {
|
||||
t.Fatalf("environment backend port = %d, %v, want 4323", got, err)
|
||||
}
|
||||
if got, err := resolvePortsForConfig(Options{BackendPort: 4326}, cfg); err != nil || got != 4326 {
|
||||
t.Fatalf("flag backend port = %d, %v, want 4326", got, err)
|
||||
}
|
||||
if got, err := resolveWebPortForConfig(Options{}, cfg); err != nil || got != 4325 {
|
||||
t.Fatalf("environment web port = %d, %v, want 4325", got, err)
|
||||
}
|
||||
if got, err := resolveWebPortForConfig(Options{WebPort: 4327}, cfg); err != nil || got != 4327 {
|
||||
t.Fatalf("flag web port = %d, %v, want 4327", got, err)
|
||||
}
|
||||
if got := resolveLogLevelForConfig(Options{}, cfg); got != "warn" {
|
||||
t.Fatalf("environment log level = %q, want warn", got)
|
||||
}
|
||||
if got := healthTimeoutForConfig(45000, cfg); got != 23456*time.Millisecond {
|
||||
t.Fatalf("environment health timeout = %s, want 23.456s", got)
|
||||
}
|
||||
if shouldOpenBrowser(Options{}, cfg) {
|
||||
t.Fatal("environment no-browser override did not suppress the browser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherBackendEnvironmentHandsOffSelectedConfigFile(t *testing.T) {
|
||||
clearLauncherConfigurationEnvironment(t)
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.yaml")
|
||||
writeLauncherConfig(t, configPath, "database:\n path: /srv/custom.db\n")
|
||||
cfg, err := config.LoadWithPath(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
env := backendEnvForConfig(
|
||||
portConfig{BackendPort: 4321, AgentctlPort: 4322},
|
||||
"info",
|
||||
"warn",
|
||||
false,
|
||||
"health-token",
|
||||
nil,
|
||||
cfg,
|
||||
)
|
||||
if got := envValue(env, config.InternalConfigFileEnv); got != configPath {
|
||||
t.Fatalf("selected config handoff = %q, want %q", got, configPath)
|
||||
}
|
||||
if got := envValue(env, "KANDEV_DATABASE_PATH"); got != "" {
|
||||
t.Fatalf("YAML database path was copied into public environment as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherSelectedConfigHandoffSurvivesWorkingDirectoryChange(t *testing.T) {
|
||||
clearLauncherConfigurationEnvironment(t)
|
||||
sourceDir := t.TempDir()
|
||||
otherDir := t.TempDir()
|
||||
configPath := filepath.Join(sourceDir, "config.yaml")
|
||||
writeLauncherConfig(t, configPath, "server:\n port: 4321\n")
|
||||
t.Setenv(config.InternalConfigFileEnv, configPath)
|
||||
t.Chdir(otherDir)
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Source.FilePath != configPath {
|
||||
t.Fatalf("child selected config = %q, want %q", cfg.Source.FilePath, configPath)
|
||||
}
|
||||
if cfg.Server.Port != 4321 {
|
||||
t.Fatalf("child server port = %d, want 4321", cfg.Server.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevBootstrapUsesConfiguredWebPort(t *testing.T) {
|
||||
clearLauncherConfigurationEnvironment(t)
|
||||
repo := makeRepoTree(t)
|
||||
t.Chdir(repo)
|
||||
writeLauncherConfig(t, filepath.Join(repo, "config.yaml"), "launcher:\n webPort: 4328\n")
|
||||
|
||||
cfg, code := devLaunchConfigFor(Options{Command: CommandDev})
|
||||
if code != 0 {
|
||||
t.Fatalf("devLaunchConfigFor() code = %d, want 0", code)
|
||||
}
|
||||
if cfg.ports.WebPort != 4328 {
|
||||
t.Fatalf("dev web port = %d, want 4328", cfg.ports.WebPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherInvalidBootstrapReportsSelectedFile(t *testing.T) {
|
||||
clearLauncherConfigurationEnvironment(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
configPath := filepath.Join(dir, "config.yaml")
|
||||
writeLauncherConfig(t, configPath, "launcher:\n healthTimeoutMs: invalid\n")
|
||||
|
||||
_, err := loadBootstrapConfig()
|
||||
if err == nil {
|
||||
t.Fatal("loadBootstrapConfig() succeeded for invalid launcher configuration")
|
||||
}
|
||||
if !strings.Contains(err.Error(), configPath) {
|
||||
t.Fatalf("error = %v, want selected file %q", err, configPath)
|
||||
}
|
||||
}
|
||||
|
||||
func clearLauncherConfigurationEnvironment(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, name := range []string{
|
||||
"KANDEV_HOME_DIR",
|
||||
"KANDEV_SERVER_HOST",
|
||||
"KANDEV_DATABASE_PATH",
|
||||
"KANDEV_LOG_LEVEL",
|
||||
"KANDEV_HEALTH_TIMEOUT_MS",
|
||||
"KANDEV_NO_BROWSER",
|
||||
config.InternalConfigFileEnv,
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
for _, name := range []string{"KANDEV_SERVER_PORT", "KANDEV_BACKEND_PORT", "KANDEV_PORT", "KANDEV_WEB_PORT"} {
|
||||
unsetEnvForTest(t, name)
|
||||
}
|
||||
}
|
||||
|
||||
func writeLauncherConfig(t *testing.T, path, contents string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -27,7 +29,7 @@ func runDev(ctx context.Context, opts Options) int {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 1
|
||||
}
|
||||
logStartup("dev mode: using local repo", cfg.ports, cfg.dbPath, cfg.logLevel)
|
||||
logStartup("dev mode: using local repo", cfg.ports, cfg.dbPath, cfg.logLevel, serverHostForConfig(cfg.startup))
|
||||
|
||||
ignoreBrokenPipeSignal()
|
||||
setLauncherShutdownDebug(cfg.debug || os.Getenv("KANDEV_SHUTDOWN_DEBUG") == "1")
|
||||
@@ -40,7 +42,7 @@ func runDev(ctx context.Context, opts Options) int {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 1
|
||||
}
|
||||
env := backendEnv(cfg.ports, cfg.logLevel, resolveConsoleLogLevel(opts), opts.Debug, healthToken, cfg.extra)
|
||||
env := backendEnvForConfig(cfg.ports, cfg.logLevel, resolveConsoleLogLevel(opts), opts.Debug, healthToken, cfg.extra, cfg.startup)
|
||||
env = upsertEnv(env, backendPIDFileEnv, filepath.Join(devKandevHome(cfg.repoRoot), "supervisor", "backend.pid"))
|
||||
backend, dumpLogs, err := launchBackendFn(backendLaunchConfig{
|
||||
Command: "make",
|
||||
@@ -50,7 +52,7 @@ func runDev(ctx context.Context, opts Options) int {
|
||||
Quiet: false,
|
||||
Ports: cfg.ports,
|
||||
Mode: "dev",
|
||||
HomeDir: devKandevHome(cfg.repoRoot),
|
||||
HomeDir: cfg.homeDir,
|
||||
Supervisor: supervisor,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -60,7 +62,7 @@ func runDev(ctx context.Context, opts Options) int {
|
||||
}
|
||||
|
||||
fmt.Println("[kandev] starting backend...")
|
||||
if err := waitForHealthFn(ctx, cfg.ports.BackendURL, backend, healthTimeout(healthTimeoutDevMS), healthToken, dumpLogs); err != nil {
|
||||
if err := waitForHealthFn(ctx, cfg.ports.BackendURL, backend, healthTimeoutForConfig(healthTimeoutDevMS, cfg.startup), healthToken, dumpLogs); err != nil {
|
||||
supervisor.shutdown("backend health failure")
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 1
|
||||
@@ -75,13 +77,13 @@ func runDev(ctx context.Context, opts Options) int {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 1
|
||||
}
|
||||
if err := waitForURLFn(ctx, webURL, webProc, healthTimeout(healthTimeoutDevMS)); err != nil {
|
||||
if err := waitForURLFn(ctx, webURL, webProc, healthTimeoutForConfig(healthTimeoutDevMS, cfg.startup)); err != nil {
|
||||
supervisor.shutdown("web readiness failure")
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 1
|
||||
}
|
||||
|
||||
if opts.Headless {
|
||||
if !shouldOpenBrowser(opts, cfg.startup) {
|
||||
fmt.Printf("[kandev] ready (headless) at %s\n", cfg.ports.BackendURL)
|
||||
return waitForAppExit(supervisor, backend, webProc)
|
||||
}
|
||||
@@ -97,12 +99,18 @@ type devLaunchConfig struct {
|
||||
extra []string
|
||||
logLevel string
|
||||
debug bool
|
||||
homeDir string
|
||||
startup *config.Config
|
||||
}
|
||||
|
||||
// devLaunchConfigFor resolves everything runDev needs before any process is
|
||||
// spawned: repo root, dev ports, and the dev backend env. It returns a
|
||||
// nonzero exit code (having printed the error) when any step fails.
|
||||
func devLaunchConfigFor(opts Options) (devLaunchConfig, int) {
|
||||
func devLaunchConfigFor(opts Options, configs ...*config.Config) (devLaunchConfig, int) {
|
||||
startupConfig, exitCode := devStartupConfig(configs...)
|
||||
if exitCode != 0 {
|
||||
return devLaunchConfig{}, exitCode
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
@@ -113,12 +121,12 @@ func devLaunchConfigFor(opts Options) (devLaunchConfig, int) {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return devLaunchConfig{}, 2
|
||||
}
|
||||
backendPort, err := resolvePorts(opts)
|
||||
backendPort, err := resolvePorts(opts, startupConfig)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return devLaunchConfig{}, 2
|
||||
}
|
||||
webPort, err := resolveWebPort(opts)
|
||||
webPort, err := resolveWebPort(opts, startupConfig)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return devLaunchConfig{}, 2
|
||||
@@ -128,17 +136,63 @@ func devLaunchConfigFor(opts Options) (devLaunchConfig, int) {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return devLaunchConfig{}, 1
|
||||
}
|
||||
dbPath, extra := resolveDevBackendEnv(repoRoot)
|
||||
dbPath, extra := resolveDevBackendEnv(repoRoot, startupConfig)
|
||||
homeDir := devKandevHome(repoRoot)
|
||||
if !isInsideKandevTask(repoRoot) && configSourceIsExplicit(startupConfig, "homeDir") {
|
||||
homeDir = startupConfig.ResolvedHomeDir()
|
||||
}
|
||||
return devLaunchConfig{
|
||||
repoRoot: repoRoot,
|
||||
ports: ports,
|
||||
dbPath: dbPath,
|
||||
extra: extra,
|
||||
logLevel: resolveLogLevel(opts),
|
||||
logLevel: resolveLogLevelForConfig(opts, startupConfig),
|
||||
debug: opts.Debug,
|
||||
homeDir: homeDir,
|
||||
startup: startupConfig,
|
||||
}, 0
|
||||
}
|
||||
|
||||
func devStartupConfig(configs ...*config.Config) (*config.Config, int) {
|
||||
if len(configs) > 0 {
|
||||
return configs[0], 0
|
||||
}
|
||||
// Load dev defaults with the same selector that the child receives.
|
||||
if os.Getenv("KANDEV_E2E_MOCK") == "" {
|
||||
if err := os.Setenv("KANDEV_DEBUG_DEV_MODE", "true"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return nil, 1
|
||||
}
|
||||
}
|
||||
startupConfig, err := loadDevBootstrapConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return nil, 1
|
||||
}
|
||||
return startupConfig, 0
|
||||
}
|
||||
|
||||
func loadDevBootstrapConfig() (*config.Config, error) {
|
||||
if os.Getenv("KANDEV_E2E_MOCK") != "" || os.Getenv("KANDEV_DEBUG_DEV_MODE") != "" {
|
||||
return loadBootstrapConfig()
|
||||
}
|
||||
|
||||
const selector = "KANDEV_DEBUG_DEV_MODE"
|
||||
previous, present := os.LookupEnv(selector)
|
||||
if err := os.Setenv(selector, "true"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := loadBootstrapConfig()
|
||||
if present {
|
||||
if restoreErr := os.Setenv(selector, previous); err == nil {
|
||||
err = restoreErr
|
||||
}
|
||||
} else if restoreErr := os.Unsetenv(selector); err == nil {
|
||||
err = restoreErr
|
||||
}
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
// backupProductionDBIfNeeded snapshots the resolved DB before dev mode runs
|
||||
// against it, unless it already lives under a .kandev-dev segment.
|
||||
func backupProductionDBIfNeeded(dbPath string) error {
|
||||
|
||||
@@ -150,6 +150,61 @@ func TestRunDevLaunchesBackendThenWebAndOpensBrowser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDevUsesConfiguredHealthTimeoutForBackendAndWeb(t *testing.T) {
|
||||
repo := makeRepoTree(t)
|
||||
t.Chdir(repo)
|
||||
writeLauncherConfig(t, filepath.Join(repo, "config.yaml"), "launcher:\n healthTimeoutMs: 12345\n")
|
||||
t.Setenv("KANDEV_TASK_ID", "")
|
||||
t.Setenv("KANDEV_DATABASE_PATH", "")
|
||||
t.Setenv("KANDEV_HOME_DIR", "")
|
||||
|
||||
oldNewSupervisor := newSupervisorFn
|
||||
oldAttachSignals := attachSignalsFn
|
||||
oldLaunchBackend := launchBackendFn
|
||||
oldWaitForHealth := waitForHealthFn
|
||||
oldWaitForURL := waitForURLFn
|
||||
oldOpenBrowser := openBrowserFn
|
||||
oldStartProcess := startProcessFn
|
||||
t.Cleanup(func() {
|
||||
newSupervisorFn = oldNewSupervisor
|
||||
attachSignalsFn = oldAttachSignals
|
||||
launchBackendFn = oldLaunchBackend
|
||||
waitForHealthFn = oldWaitForHealth
|
||||
waitForURLFn = oldWaitForURL
|
||||
openBrowserFn = oldOpenBrowser
|
||||
startProcessFn = oldStartProcess
|
||||
})
|
||||
|
||||
newSupervisorFn = newSupervisor
|
||||
attachSignalsFn = func(_ *processSupervisor) {}
|
||||
launchBackendFn = func(_ backendLaunchConfig) (*restartableBackend, func(), error) {
|
||||
exitCh := make(chan int, 1)
|
||||
exitCh <- 0
|
||||
return &restartableBackend{exitCh: exitCh}, func() {}, nil
|
||||
}
|
||||
var backendTimeout, webTimeout time.Duration
|
||||
waitForHealthFn = func(_ context.Context, _ string, _ childState, timeout time.Duration, _ string, _ func()) error {
|
||||
backendTimeout = timeout
|
||||
return nil
|
||||
}
|
||||
waitForURLFn = func(_ context.Context, _ string, _ childState, timeout time.Duration) error {
|
||||
webTimeout = timeout
|
||||
return nil
|
||||
}
|
||||
openBrowserFn = func(string) {}
|
||||
startProcessFn = func(_ string, _ []string, _ string, _ []string, _ bool, _ string, _ *processSupervisor) (*managedProcess, func(), error) {
|
||||
return &managedProcess{}, func() {}, nil
|
||||
}
|
||||
|
||||
if code := runDev(context.Background(), Options{Command: CommandDev}); code != 0 {
|
||||
t.Fatalf("runDev() = %d, want 0", code)
|
||||
}
|
||||
want := 12345 * time.Millisecond
|
||||
if backendTimeout != want || webTimeout != want {
|
||||
t.Fatalf("health timeouts = backend %s, web %s; want %s for both", backendTimeout, webTimeout, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDevHeadlessSkipsBrowser(t *testing.T) {
|
||||
repo := makeRepoTree(t)
|
||||
t.Chdir(repo)
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
)
|
||||
|
||||
// findRepoRoot returns the nearest ancestor of startDir that contains both
|
||||
@@ -61,9 +63,13 @@ func isInsideKandevTask(repoRoot string) bool {
|
||||
// The returned dbPath is display-only; the backend derives its own DB path
|
||||
// from KANDEV_HOME_DIR via resolveDatabasePath(). Both resolve to the same
|
||||
// location.
|
||||
func resolveDevBackendEnv(repoRoot string) (dbPath string, extra []string) {
|
||||
func resolveDevBackendEnv(repoRoot string, configs ...*config.Config) (dbPath string, extra []string) {
|
||||
devHome := devKandevHome(repoRoot)
|
||||
devDBPath := filepath.Join(devHome, "data", "kandev.db")
|
||||
var startupConfig *config.Config
|
||||
if len(configs) > 0 {
|
||||
startupConfig = configs[0]
|
||||
}
|
||||
|
||||
// Profile-selector only: the backend reads profiles.yaml at startup and
|
||||
// applies the matching dev: values (mock agent, pprof, feature flags,
|
||||
@@ -81,6 +87,15 @@ func resolveDevBackendEnv(repoRoot string) (dbPath string, extra []string) {
|
||||
)
|
||||
}
|
||||
|
||||
if startupConfig != nil && startupConfig.SourceFor("database.path") == config.SourceConfiguration &&
|
||||
strings.TrimSpace(startupConfig.Database.Path) != "" {
|
||||
return startupConfig.Database.Path, baseExtra
|
||||
}
|
||||
if startupConfig != nil && (startupConfig.SourceFor("homeDir") == config.SourceConfiguration ||
|
||||
startupConfig.SourceFor("homeDir") == config.SourceEnvironment) {
|
||||
return filepath.Join(startupConfig.ResolvedDataDir(), "kandev.db"), baseExtra
|
||||
}
|
||||
|
||||
if override := strings.TrimSpace(os.Getenv("KANDEV_DATABASE_PATH")); override != "" {
|
||||
return override, append(baseExtra, "KANDEV_DATABASE_PATH="+override)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commonconfig "github.com/kandev/kandev/internal/common/config"
|
||||
)
|
||||
|
||||
func makeRepoTree(t *testing.T) string {
|
||||
@@ -172,6 +174,28 @@ func TestResolveDevBackendEnvIgnoresWhitespaceOnlyOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevBackendEnvHonorsEnvironmentConfiguredHome(t *testing.T) {
|
||||
repo := makeRepoTree(t)
|
||||
customHome := filepath.Join(t.TempDir(), "kandev")
|
||||
t.Setenv("KANDEV_HOME_DIR", customHome)
|
||||
t.Setenv("KANDEV_TASK_ID", "")
|
||||
t.Setenv("KANDEV_DATABASE_PATH", "")
|
||||
|
||||
cfg := &commonconfig.Config{
|
||||
HomeDir: customHome,
|
||||
Source: commonconfig.ConfigSource{Values: map[string]commonconfig.SettingSource{
|
||||
"homeDir": commonconfig.SourceEnvironment,
|
||||
}},
|
||||
}
|
||||
dbPath, extra := resolveDevBackendEnv(repo, cfg)
|
||||
if want := filepath.Join(customHome, "data", "kandev.db"); dbPath != want {
|
||||
t.Fatalf("dbPath = %q, want %q", dbPath, want)
|
||||
}
|
||||
if env := devEnvToMap(extra); env["KANDEV_HOME_DIR"] != "" {
|
||||
t.Fatalf("dev extra environment overwrote configured home: %q", env["KANDEV_HOME_DIR"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevBackendEnvClearsLeakedPathInTaskWorkspace(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/profiles"
|
||||
)
|
||||
|
||||
const healthTokenBytes = 32
|
||||
@@ -29,16 +32,31 @@ func launchHealthToken() (string, error) {
|
||||
return newHealthToken()
|
||||
}
|
||||
|
||||
func backendEnv(ports portConfig, logLevel, consoleLogLevel string, debug bool, healthToken string, extra []string) []string {
|
||||
env := os.Environ()
|
||||
func backendEnv(ports portConfig, logLevel, consoleLogLevel string, debug bool, healthToken string, extra []string, configs ...*config.Config) []string {
|
||||
if len(configs) > 0 {
|
||||
return backendEnvForConfig(ports, logLevel, consoleLogLevel, debug, healthToken, extra, configs[0])
|
||||
}
|
||||
return backendEnvForConfig(ports, logLevel, consoleLogLevel, debug, healthToken, extra, nil)
|
||||
}
|
||||
|
||||
func backendEnvForConfig(ports portConfig, logLevel, consoleLogLevel string, debug bool, healthToken string, extra []string, cfg *config.Config) []string {
|
||||
env := stripAppliedProfileEnvironment(os.Environ())
|
||||
env = upsertEnv(env, "KANDEV_SERVER_PORT", fmt.Sprint(ports.BackendPort))
|
||||
env = upsertEnv(env, "KANDEV_AGENT_STANDALONE_PORT", fmt.Sprint(ports.AgentctlPort))
|
||||
env = upsertEnv(env, "KANDEV_DATABASE_PATH", resolveDatabasePath())
|
||||
if cfg == nil || cfg.SourceFor("database.path") != config.SourceConfiguration || strings.TrimSpace(cfg.Database.Path) == "" {
|
||||
env = upsertEnv(env, "KANDEV_DATABASE_PATH", resolveDatabasePathForConfig(cfg))
|
||||
}
|
||||
if configPath := configFileForChild(cfg); configPath != "" {
|
||||
env = upsertEnv(env, config.InternalConfigFileEnv, configPath)
|
||||
if cfg.Source.HomeFile {
|
||||
env = upsertEnv(env, config.InternalConfigHomeFileEnv, "1")
|
||||
}
|
||||
}
|
||||
env = upsertEnv(env, "KANDEV_DESKTOP_HEALTH_TOKEN", healthToken)
|
||||
if ports.WebPort != 0 {
|
||||
env = upsertEnv(env, "KANDEV_WEB_INTERNAL_URL", fmt.Sprintf("http://localhost:%d", ports.WebPort))
|
||||
}
|
||||
if logLevel != "" {
|
||||
if logLevel != "" && (cfg == nil || cfg.SourceFor("logging.level") != config.SourceConfiguration || cfg.Logging.Level != logLevel) {
|
||||
env = upsertEnv(env, "KANDEV_LOG_LEVEL", logLevel)
|
||||
}
|
||||
env = upsertEnv(env, "KANDEV_CONSOLE_LOG_LEVEL", consoleLogLevel)
|
||||
@@ -56,6 +74,22 @@ func backendEnv(ports portConfig, logLevel, consoleLogLevel string, debug bool,
|
||||
return env
|
||||
}
|
||||
|
||||
func stripAppliedProfileEnvironment(env []string) []string {
|
||||
defaults, err := profiles.EnvironmentDefaults()
|
||||
if err != nil {
|
||||
return env
|
||||
}
|
||||
filtered := make([]string, 0, len(env))
|
||||
for _, item := range env {
|
||||
key, value, ok := strings.Cut(item, "=")
|
||||
if ok && profiles.WasApplied(key) && defaults[key] == value {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func upsertEnv(env []string, key, value string) []string {
|
||||
prefix := key + "="
|
||||
for i, item := range env {
|
||||
|
||||
@@ -9,7 +9,10 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -38,6 +41,18 @@ func healthTimeout(defaultMS int) time.Duration {
|
||||
return time.Duration(n) * time.Millisecond
|
||||
}
|
||||
|
||||
func healthTimeoutForConfig(defaultMS int, cfg *config.Config) time.Duration {
|
||||
if raw, ok := os.LookupEnv("KANDEV_HEALTH_TIMEOUT_MS"); ok && strings.TrimSpace(raw) != "" {
|
||||
return healthTimeout(defaultMS)
|
||||
}
|
||||
if configSourceIsExplicit(cfg, "launcher.healthTimeoutMs") || cfg != nil {
|
||||
if cfg != nil && cfg.Launcher.HealthTimeoutMs > 0 {
|
||||
return time.Duration(cfg.Launcher.HealthTimeoutMs) * time.Millisecond
|
||||
}
|
||||
}
|
||||
return healthTimeout(defaultMS)
|
||||
}
|
||||
|
||||
func waitForHealth(ctx context.Context, baseURL string, proc childState, timeout time.Duration, expectedToken string, onFailure func()) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
)
|
||||
|
||||
type portConfig struct {
|
||||
@@ -24,7 +26,10 @@ const (
|
||||
webPortEnv = "KANDEV_WEB_PORT"
|
||||
)
|
||||
|
||||
func resolvePorts(opts Options) (int, error) {
|
||||
func resolvePorts(opts Options, configs ...*config.Config) (int, error) {
|
||||
if len(configs) > 0 {
|
||||
return resolvePortsForConfig(opts, configs[0])
|
||||
}
|
||||
backend := opts.BackendPort
|
||||
if backend == 0 {
|
||||
if p, err := envPort(backendPortEnv); err != nil {
|
||||
@@ -40,15 +45,62 @@ func resolvePorts(opts Options) (int, error) {
|
||||
return backend, nil
|
||||
}
|
||||
|
||||
func resolvePortsForConfig(opts Options, cfg *config.Config) (int, error) {
|
||||
if opts.BackendPort != 0 {
|
||||
return opts.BackendPort, nil
|
||||
}
|
||||
if _, ok := os.LookupEnv(backendPortEnv); ok {
|
||||
return resolvePorts(opts)
|
||||
}
|
||||
if _, ok := os.LookupEnv(legacyBackendPortEnv); ok {
|
||||
return resolvePorts(opts)
|
||||
}
|
||||
// KANDEV_SERVER_PORT is the child-backend handoff variable. The launcher
|
||||
// has historically resolved its own port from the flag, KANDEV_BACKEND_PORT,
|
||||
// or KANDEV_PORT, so an inherited child value must not pin a dev launcher to
|
||||
// an already-running parent backend.
|
||||
if cfg != nil && (cfg.SourceFor("server.port") == config.SourceConfiguration ||
|
||||
(cfg.SourceFor("server.port") == config.SourceEnvironment && serviceEnvironmentActive())) {
|
||||
if cfg.Server.Port < 1 || cfg.Server.Port > 65535 {
|
||||
return 0, ParseError{Message: fmt.Sprintf("server.port must be an integer between 1 and 65535, got %d", cfg.Server.Port)}
|
||||
}
|
||||
return cfg.Server.Port, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func serviceEnvironmentActive() bool {
|
||||
return os.Getenv("KANDEV_SERVICE_MODE") != "" || os.Getenv("KANDEV_RUNNING_AS_SERVICE") != ""
|
||||
}
|
||||
|
||||
// resolveWebPort resolves the dev-mode web port: the --web-internal-port flag
|
||||
// beats KANDEV_WEB_PORT, and 0 means "unset".
|
||||
func resolveWebPort(opts Options) (int, error) {
|
||||
func resolveWebPort(opts Options, configs ...*config.Config) (int, error) {
|
||||
if len(configs) > 0 {
|
||||
return resolveWebPortForConfig(opts, configs[0])
|
||||
}
|
||||
if opts.WebPort != 0 {
|
||||
return opts.WebPort, nil
|
||||
}
|
||||
return envPort(webPortEnv)
|
||||
}
|
||||
|
||||
func resolveWebPortForConfig(opts Options, cfg *config.Config) (int, error) {
|
||||
if opts.WebPort != 0 {
|
||||
return opts.WebPort, nil
|
||||
}
|
||||
if _, ok := os.LookupEnv(webPortEnv); ok {
|
||||
return envPort(webPortEnv)
|
||||
}
|
||||
if cfg != nil && cfg.SourceFor("launcher.webPort") == config.SourceConfiguration {
|
||||
if cfg.Launcher.WebPort < 1 || cfg.Launcher.WebPort > 65535 {
|
||||
return 0, ParseError{Message: fmt.Sprintf("launcher.webPort must be an integer between 1 and 65535, got %d", cfg.Launcher.WebPort)}
|
||||
}
|
||||
return cfg.Launcher.WebPort, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func backendPortSource(opts Options) string {
|
||||
if opts.BackendPort != 0 {
|
||||
return backendPortFlag
|
||||
|
||||
@@ -8,7 +8,12 @@ import (
|
||||
)
|
||||
|
||||
func runInstalled(ctx context.Context, opts Options) int {
|
||||
backendPort, err := resolvePorts(opts)
|
||||
startupConfig, err := loadBootstrapConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 1
|
||||
}
|
||||
backendPort, err := resolvePorts(opts, startupConfig)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 2
|
||||
@@ -23,12 +28,12 @@ func runInstalled(ctx context.Context, opts Options) int {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 1
|
||||
}
|
||||
if err := ensureDataDir(); err != nil {
|
||||
if err := ensureDataDirForConfig(startupConfig); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 1
|
||||
}
|
||||
|
||||
logLevel := resolveLogLevel(opts)
|
||||
logLevel := resolveLogLevelForConfig(opts, startupConfig)
|
||||
releaseTag := os.Getenv("KANDEV_VERSION")
|
||||
if releaseTag == "" {
|
||||
releaseTag = "(" + bundle.Source + ")"
|
||||
@@ -41,5 +46,6 @@ func runInstalled(ctx context.Context, opts Options) int {
|
||||
Ports: ports,
|
||||
LogLevel: logLevel,
|
||||
Opts: opts,
|
||||
Startup: startupConfig,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
)
|
||||
|
||||
type serviceArgs struct {
|
||||
@@ -20,22 +22,25 @@ type serviceArgs struct {
|
||||
Intent string
|
||||
DryRun bool
|
||||
ShowHelp bool
|
||||
Startup *config.Config
|
||||
}
|
||||
|
||||
const (
|
||||
actionInstall = "install"
|
||||
actionUninstall = "uninstall"
|
||||
actionRestart = "restart"
|
||||
actionConfig = "config"
|
||||
actionLogs = "logs"
|
||||
actionStatus = "status"
|
||||
actionStop = "stop"
|
||||
actionSelfUpdate = "self-update"
|
||||
flagHelp = "--help"
|
||||
goosLinux = "linux"
|
||||
goosDarwin = "darwin"
|
||||
managedMarker = "managed by kandev"
|
||||
serviceUnitName = "kandev.service"
|
||||
actionInstall = "install"
|
||||
actionUninstall = "uninstall"
|
||||
actionRestart = "restart"
|
||||
actionConfig = "config"
|
||||
actionLogs = "logs"
|
||||
actionStatus = "status"
|
||||
actionStop = "stop"
|
||||
actionSelfUpdate = "self-update"
|
||||
flagHelp = "--help"
|
||||
goosLinux = "linux"
|
||||
goosDarwin = "darwin"
|
||||
managedMarker = "managed by kandev"
|
||||
serviceUnitName = "kandev.service"
|
||||
defaultServiceLogLevel = "info"
|
||||
defaultSystemServiceHomeDir = "/var/lib/kandev"
|
||||
)
|
||||
|
||||
const serviceHelp = `kandev service — install kandev as an OS-managed service
|
||||
@@ -74,6 +79,20 @@ func runService(argv []string, build BuildInfo) int {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if args.Action == actionInstall {
|
||||
startupConfig, err := loadServiceBootstrapConfig(args)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 1
|
||||
}
|
||||
args.Startup = startupConfig
|
||||
port, err := resolvePortsForConfig(Options{BackendPort: args.Port}, startupConfig)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 2
|
||||
}
|
||||
args.Port = port
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case goosLinux:
|
||||
return runLinuxService(args, build)
|
||||
@@ -250,7 +269,7 @@ func installSystemd(args serviceArgs, build BuildInfo, unitPath string) int {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 1
|
||||
}
|
||||
homeDir := serviceHomeDir(args)
|
||||
homeDir, homeSource := resolveServiceHome(args)
|
||||
logDir := filepath.Join(homeDir, "logs")
|
||||
bundleDir := serviceBundleDir(self)
|
||||
systemUser, err := resolveAndValidateSystemIdentity(args, unitPath, nativeServiceManagerSystemd, homeDir)
|
||||
@@ -259,16 +278,20 @@ func installSystemd(args serviceArgs, build BuildInfo, unitPath string) int {
|
||||
return 1
|
||||
}
|
||||
input := nativeServiceUnitInput{
|
||||
Executable: self,
|
||||
HomeDir: homeDir,
|
||||
LogDir: logDir,
|
||||
Port: args.Port,
|
||||
System: args.System,
|
||||
SystemUser: systemUser,
|
||||
NoBootStart: args.NoBootStart,
|
||||
BundleDir: bundleDir,
|
||||
Version: serviceVersion(build.Version),
|
||||
PathPrefixes: serviceNodeToolBinDirs(),
|
||||
Executable: self,
|
||||
HomeDir: homeDir,
|
||||
LogDir: logDir,
|
||||
Port: args.Port,
|
||||
System: args.System,
|
||||
SystemUser: systemUser,
|
||||
NoBootStart: args.NoBootStart,
|
||||
BundleDir: bundleDir,
|
||||
Version: serviceVersion(build.Version),
|
||||
PathPrefixes: serviceNodeToolBinDirs(),
|
||||
ConfigFile: configFileForChild(args.Startup),
|
||||
HomeDirFromConfig: homeSource == config.SourceConfiguration,
|
||||
ConfigHomeFile: args.Startup != nil && args.Startup.Source.HomeFile,
|
||||
LogLevel: serviceLogLevel(args.Startup),
|
||||
}
|
||||
metadata := buildNativeServiceMetadata(nativeServiceManagerSystemd, args, input, unitPath)
|
||||
prepared, err := prepareNativeServiceMetadata(metadata)
|
||||
@@ -363,7 +386,7 @@ func installLaunchd(args serviceArgs, build BuildInfo, plistPath, target, domain
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 1
|
||||
}
|
||||
homeDir := serviceHomeDir(args)
|
||||
homeDir, homeSource := resolveServiceHome(args)
|
||||
logDir := filepath.Join(homeDir, "logs")
|
||||
bundleDir := serviceBundleDir(self)
|
||||
systemUser, err := resolveAndValidateSystemIdentity(args, plistPath, nativeServiceManagerLaunchd, homeDir)
|
||||
@@ -372,16 +395,20 @@ func installLaunchd(args serviceArgs, build BuildInfo, plistPath, target, domain
|
||||
return 1
|
||||
}
|
||||
input := nativeServiceUnitInput{
|
||||
Executable: self,
|
||||
HomeDir: homeDir,
|
||||
LogDir: logDir,
|
||||
Port: args.Port,
|
||||
System: args.System,
|
||||
SystemUser: systemUser,
|
||||
NoBootStart: args.NoBootStart,
|
||||
BundleDir: bundleDir,
|
||||
Version: serviceVersion(build.Version),
|
||||
PathPrefixes: serviceNodeToolBinDirs(),
|
||||
Executable: self,
|
||||
HomeDir: homeDir,
|
||||
LogDir: logDir,
|
||||
Port: args.Port,
|
||||
System: args.System,
|
||||
SystemUser: systemUser,
|
||||
NoBootStart: args.NoBootStart,
|
||||
BundleDir: bundleDir,
|
||||
Version: serviceVersion(build.Version),
|
||||
PathPrefixes: serviceNodeToolBinDirs(),
|
||||
ConfigFile: configFileForChild(args.Startup),
|
||||
HomeDirFromConfig: homeSource == config.SourceConfiguration,
|
||||
ConfigHomeFile: args.Startup != nil && args.Startup.Source.HomeFile,
|
||||
LogLevel: serviceLogLevel(args.Startup),
|
||||
}
|
||||
metadata := buildNativeServiceMetadata(nativeServiceManagerLaunchd, args, input, plistPath)
|
||||
prepared, err := prepareNativeServiceMetadata(metadata)
|
||||
@@ -436,16 +463,20 @@ func installLaunchd(args serviceArgs, build BuildInfo, plistPath, target, domain
|
||||
}
|
||||
|
||||
type nativeServiceUnitInput struct {
|
||||
Executable string
|
||||
HomeDir string
|
||||
LogDir string
|
||||
Port int
|
||||
System bool
|
||||
SystemUser string
|
||||
NoBootStart bool
|
||||
BundleDir string
|
||||
Version string
|
||||
PathPrefixes []string
|
||||
Executable string
|
||||
HomeDir string
|
||||
LogDir string
|
||||
Port int
|
||||
System bool
|
||||
SystemUser string
|
||||
NoBootStart bool
|
||||
BundleDir string
|
||||
Version string
|
||||
PathPrefixes []string
|
||||
ConfigFile string
|
||||
HomeDirFromConfig bool
|
||||
ConfigHomeFile bool
|
||||
LogLevel string
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -543,8 +574,6 @@ func cleanServicePathDirs(dirs []string) []string {
|
||||
func renderSystemdUnit(input nativeServiceUnitInput) string {
|
||||
pathValue := servicePathWithPrefixes(systemdServicePath, input.PathPrefixes)
|
||||
env := []string{
|
||||
serviceEnvLine("KANDEV_HOME_DIR", input.HomeDir),
|
||||
serviceEnvLine("KANDEV_LOG_LEVEL", "info"),
|
||||
serviceEnvLineAllowSpecifiers("PATH", pathValue),
|
||||
serviceEnvLine("KANDEV_RUNNING_AS_SERVICE", "true"),
|
||||
serviceEnvLine("KANDEV_SERVICE_MODE", nativeServiceMode(input.System)),
|
||||
@@ -552,6 +581,22 @@ func renderSystemdUnit(input nativeServiceUnitInput) string {
|
||||
serviceEnvLine("KANDEV_INSTALL_KIND", nativeInstallKind(input.Executable, input.BundleDir)),
|
||||
serviceEnvLine("KANDEV_SERVICE_METADATA", nativeServiceMetadataPath(input.HomeDir)),
|
||||
}
|
||||
if !input.HomeDirFromConfig {
|
||||
env = append([]string{serviceEnvLine("KANDEV_HOME_DIR", input.HomeDir)}, env...)
|
||||
}
|
||||
if input.LogLevel != "" || input.ConfigFile == "" {
|
||||
level := input.LogLevel
|
||||
if level == "" {
|
||||
level = defaultServiceLogLevel
|
||||
}
|
||||
env = append([]string{serviceEnvLine("KANDEV_LOG_LEVEL", level)}, env...)
|
||||
}
|
||||
if input.ConfigFile != "" {
|
||||
env = append(env, serviceEnvLine(config.InternalConfigFileEnv, input.ConfigFile))
|
||||
if input.ConfigHomeFile {
|
||||
env = append(env, serviceEnvLine(config.InternalConfigHomeFileEnv, "1"))
|
||||
}
|
||||
}
|
||||
if input.Port != 0 {
|
||||
env = append(env, serviceEnvLine("KANDEV_SERVER_PORT", fmt.Sprint(input.Port)))
|
||||
}
|
||||
@@ -579,8 +624,6 @@ func renderSystemdUnit(input nativeServiceUnitInput) string {
|
||||
func renderLaunchdPlist(input nativeServiceUnitInput) string {
|
||||
pathValue := servicePathWithPrefixes(launchdServicePath, input.PathPrefixes)
|
||||
envEntries := [][2]string{
|
||||
{"KANDEV_HOME_DIR", input.HomeDir},
|
||||
{"KANDEV_LOG_LEVEL", "info"},
|
||||
{"PATH", pathValue},
|
||||
{"KANDEV_RUNNING_AS_SERVICE", "true"},
|
||||
{"KANDEV_SERVICE_MODE", nativeServiceMode(input.System)},
|
||||
@@ -588,6 +631,22 @@ func renderLaunchdPlist(input nativeServiceUnitInput) string {
|
||||
{"KANDEV_INSTALL_KIND", nativeInstallKind(input.Executable, input.BundleDir)},
|
||||
{"KANDEV_SERVICE_METADATA", nativeServiceMetadataPath(input.HomeDir)},
|
||||
}
|
||||
if !input.HomeDirFromConfig {
|
||||
envEntries = append([][2]string{{"KANDEV_HOME_DIR", input.HomeDir}}, envEntries...)
|
||||
}
|
||||
if input.LogLevel != "" || input.ConfigFile == "" {
|
||||
level := input.LogLevel
|
||||
if level == "" {
|
||||
level = defaultServiceLogLevel
|
||||
}
|
||||
envEntries = append([][2]string{{"KANDEV_LOG_LEVEL", level}}, envEntries...)
|
||||
}
|
||||
if input.ConfigFile != "" {
|
||||
envEntries = append(envEntries, [2]string{config.InternalConfigFileEnv, input.ConfigFile})
|
||||
if input.ConfigHomeFile {
|
||||
envEntries = append(envEntries, [2]string{config.InternalConfigHomeFileEnv, "1"})
|
||||
}
|
||||
}
|
||||
if input.Port != 0 {
|
||||
envEntries = append(envEntries, [2]string{"KANDEV_SERVER_PORT", fmt.Sprint(input.Port)})
|
||||
}
|
||||
@@ -741,13 +800,41 @@ func systemctlScope(system bool) []string {
|
||||
}
|
||||
|
||||
func serviceHomeDir(args serviceArgs) string {
|
||||
if args.HomeDir != "" {
|
||||
return args.HomeDir
|
||||
dir, _ := resolveServiceHome(args)
|
||||
return dir
|
||||
}
|
||||
|
||||
func resolveServiceHome(args serviceArgs) (string, config.SettingSource) {
|
||||
if strings.TrimSpace(args.HomeDir) != "" {
|
||||
return args.HomeDir, config.SourceFlag
|
||||
}
|
||||
if args.Startup != nil {
|
||||
source := args.Startup.SourceFor("homeDir")
|
||||
switch source {
|
||||
case config.SourceConfiguration, config.SourceEnvironment, config.SourceProfile:
|
||||
return args.Startup.ResolvedHomeDir(), source
|
||||
}
|
||||
}
|
||||
if raw := strings.TrimSpace(os.Getenv("KANDEV_HOME_DIR")); raw != "" {
|
||||
return expandHome(raw), config.SourceEnvironment
|
||||
}
|
||||
if args.System {
|
||||
return "/var/lib/kandev"
|
||||
return defaultSystemServiceHomeDir, config.SourceDefault
|
||||
}
|
||||
return resolveHomeDir()
|
||||
return resolveHomeDir(), config.SourceDefault
|
||||
}
|
||||
|
||||
func serviceLogLevel(cfg *config.Config) string {
|
||||
if cfg == nil {
|
||||
return defaultServiceLogLevel
|
||||
}
|
||||
if cfg.Source.FilePath != "" && cfg.SourceFor("logging.level") == config.SourceConfiguration {
|
||||
return ""
|
||||
}
|
||||
if cfg.Logging.Level != "" {
|
||||
return cfg.Logging.Level
|
||||
}
|
||||
return defaultServiceLogLevel
|
||||
}
|
||||
|
||||
func printServiceConfig(args serviceArgs) {
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package launcher
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServiceBootstrapHomeResolutionPrecedence(t *testing.T) {
|
||||
flagHome := filepath.Join(t.TempDir(), "flag-home")
|
||||
envHome := filepath.Join(t.TempDir(), "env-home")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args serviceArgs
|
||||
env string
|
||||
want string
|
||||
}{
|
||||
{name: "flag", args: serviceArgs{HomeDir: flagHome}, env: envHome, want: flagHome},
|
||||
{name: "environment", args: serviceArgs{}, env: envHome, want: envHome},
|
||||
{name: "system default", args: serviceArgs{System: true}, want: "/var/lib/kandev"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("KANDEV_HOME_DIR", tt.env)
|
||||
if got := serviceBootstrapHomeDir(tt.args); got != tt.want {
|
||||
t.Fatalf("serviceBootstrapHomeDir() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSystemdDiscoversFlagHomeConfiguration(t *testing.T) {
|
||||
t.Setenv("KANDEV_HOME_DIR", "")
|
||||
tmp := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(tmp, "working"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(filepath.Join(tmp, "working"))
|
||||
homeDir := filepath.Join(tmp, "flag-home")
|
||||
configPath := filepath.Join(homeDir, "config.yaml")
|
||||
writeServiceConfiguration(t, configPath, "server:\n port: 40131\n")
|
||||
|
||||
startup, err := loadServiceBootstrapConfig(serviceArgs{HomeDir: homeDir})
|
||||
if err != nil {
|
||||
t.Fatalf("loadServiceBootstrapConfig() = %v", err)
|
||||
}
|
||||
if startup.Source.FilePath != configPath {
|
||||
t.Fatalf("selected config = %q, want %q", startup.Source.FilePath, configPath)
|
||||
}
|
||||
|
||||
restoreServiceInstallHooks(t)
|
||||
executablePath = func() (string, error) { return "/opt/kandev/bin/kandev", nil }
|
||||
executeServiceCommand = func(string, ...string) error { return nil }
|
||||
servicePrintln = func(string) {}
|
||||
|
||||
unitPath := filepath.Join(tmp, "kandev.service")
|
||||
code := installSystemd(serviceArgs{
|
||||
Action: actionInstall,
|
||||
HomeDir: homeDir,
|
||||
NoBootStart: true,
|
||||
Startup: startup,
|
||||
}, BuildInfo{Version: "test"}, unitPath)
|
||||
if code != 0 {
|
||||
t.Fatalf("installSystemd() = %d, want 0", code)
|
||||
}
|
||||
unit, err := os.ReadFile(unitPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unitText := string(unit)
|
||||
if !strings.Contains(unitText, "Environment=KANDEV_INTERNAL_CONFIG_FILE="+configPath) {
|
||||
t.Fatalf("unit did not pin the flag-selected config file:\n%s", unitText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSystemdDiscoversSystemHomeConfiguration(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("system service installation is unsupported on Windows")
|
||||
}
|
||||
t.Setenv("KANDEV_HOME_DIR", "")
|
||||
tmp := t.TempDir()
|
||||
workingDir := filepath.Join(tmp, "working")
|
||||
if err := os.MkdirAll(workingDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(workingDir)
|
||||
homeDir := filepath.Join(tmp, "system-home")
|
||||
configPath := filepath.Join(homeDir, "config.yaml")
|
||||
writeServiceConfiguration(t, configPath, "server:\n port: 40132\n")
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
startup, err := loadServiceBootstrapConfig(serviceArgs{System: true, HomeDir: homeDir})
|
||||
if err != nil {
|
||||
t.Fatalf("loadServiceBootstrapConfig() = %v", err)
|
||||
}
|
||||
if startup.Source.FilePath != configPath {
|
||||
t.Fatalf("selected config = %q, want %q", startup.Source.FilePath, configPath)
|
||||
}
|
||||
|
||||
restoreServiceInstallHooks(t)
|
||||
executablePath = func() (string, error) { return "/opt/kandev/bin/kandev", nil }
|
||||
executeServiceCommand = func(string, ...string) error { return nil }
|
||||
servicePrintln = func(string) {}
|
||||
|
||||
unitPath := filepath.Join(tmp, "kandev.service")
|
||||
code := installSystemd(serviceArgs{
|
||||
Action: actionInstall,
|
||||
System: true,
|
||||
RunAs: currentUser.Username,
|
||||
HomeDir: homeDir,
|
||||
NoBootStart: true,
|
||||
Startup: startup,
|
||||
}, BuildInfo{Version: "test"}, unitPath)
|
||||
if code != 0 {
|
||||
t.Fatalf("installSystemd() = %d, want 0", code)
|
||||
}
|
||||
unit, err := os.ReadFile(unitPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unitText := string(unit)
|
||||
if !strings.Contains(unitText, "Environment=KANDEV_INTERNAL_CONFIG_FILE="+configPath) {
|
||||
t.Fatalf("system unit did not pin the system-selected config file:\n%s", unitText)
|
||||
}
|
||||
if !strings.Contains(unitText, "Environment=KANDEV_SERVICE_MODE=system") {
|
||||
t.Fatalf("system unit did not retain system mode:\n%s", unitText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceInstallHomeSourceWinsOverYAMLForBothManagers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kind string
|
||||
}{
|
||||
{name: "systemd", kind: nativeServiceManagerSystemd},
|
||||
{name: "launchd", kind: nativeServiceManagerLaunchd},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("KANDEV_HOME_DIR", "")
|
||||
tmp := t.TempDir()
|
||||
workingDir := filepath.Join(tmp, "working")
|
||||
if err := os.MkdirAll(workingDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(workingDir)
|
||||
writeServiceConfiguration(t, filepath.Join(workingDir, "config.yaml"), "homeDir: /yaml-home\n")
|
||||
flagHome := filepath.Join(tmp, "flag-home")
|
||||
startup, err := loadServiceBootstrapConfig(serviceArgs{HomeDir: flagHome})
|
||||
if err != nil {
|
||||
t.Fatalf("loadServiceBootstrapConfig() = %v", err)
|
||||
}
|
||||
|
||||
restoreServiceInstallHooks(t)
|
||||
executablePath = func() (string, error) { return "/opt/kandev/bin/kandev", nil }
|
||||
executeServiceCommand = func(name string, args ...string) error {
|
||||
if tt.kind == nativeServiceManagerLaunchd && name == "launchctl" && len(args) > 0 && args[0] == "print" {
|
||||
return errors.New("not loaded")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
servicePrintln = func(string) {}
|
||||
|
||||
output := installServiceUnitForTest(t, tt.kind, tmp, serviceArgs{Action: actionInstall, HomeDir: flagHome, NoBootStart: true, Startup: startup})
|
||||
if !strings.Contains(output, expectedServiceHomeEntry(tt.kind, flagHome)) {
|
||||
t.Fatalf("%s service definition omitted flag home:\n%s", tt.name, output)
|
||||
}
|
||||
if strings.Contains(output, "yaml-home") {
|
||||
t.Fatalf("service metadata used YAML home despite the flag:\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceInstallEnvironmentHomeWinsOverYAMLForBothManagers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kind string
|
||||
}{
|
||||
{name: "systemd", kind: nativeServiceManagerSystemd},
|
||||
{name: "launchd", kind: nativeServiceManagerLaunchd},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
workingDir := filepath.Join(tmp, "working")
|
||||
if err := os.MkdirAll(workingDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(workingDir)
|
||||
envHome := filepath.Join(tmp, "env-home")
|
||||
t.Setenv("KANDEV_HOME_DIR", envHome)
|
||||
writeServiceConfiguration(t, filepath.Join(workingDir, "config.yaml"), "homeDir: /yaml-home\n")
|
||||
startup, err := loadServiceBootstrapConfig(serviceArgs{})
|
||||
if err != nil {
|
||||
t.Fatalf("loadServiceBootstrapConfig() = %v", err)
|
||||
}
|
||||
|
||||
restoreServiceInstallHooks(t)
|
||||
executablePath = func() (string, error) { return "/opt/kandev/bin/kandev", nil }
|
||||
executeServiceCommand = func(name string, args ...string) error {
|
||||
if tt.kind == nativeServiceManagerLaunchd && name == "launchctl" && len(args) > 0 && args[0] == "print" {
|
||||
return errors.New("not loaded")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
servicePrintln = func(string) {}
|
||||
|
||||
output := installServiceUnitForTest(t, tt.kind, tmp, serviceArgs{Action: actionInstall, NoBootStart: true, Startup: startup})
|
||||
if !strings.Contains(output, expectedServiceHomeEntry(tt.kind, envHome)) {
|
||||
t.Fatalf("%s service definition omitted environment home:\n%s", tt.name, output)
|
||||
}
|
||||
if strings.Contains(output, "yaml-home") {
|
||||
t.Fatalf("service metadata used YAML home despite the environment:\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func installServiceUnitForTest(t *testing.T, kind, tmp string, args serviceArgs) string {
|
||||
t.Helper()
|
||||
switch kind {
|
||||
case nativeServiceManagerSystemd:
|
||||
path := filepath.Join(tmp, "kandev.service")
|
||||
if code := installSystemd(args, BuildInfo{}, path); code != 0 {
|
||||
t.Fatalf("installSystemd() = %d, want 0", code)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
case nativeServiceManagerLaunchd:
|
||||
path := filepath.Join(tmp, "com.kdlbs.kandev.plist")
|
||||
if code := installLaunchd(args, BuildInfo{}, path, "gui/501/com.kdlbs.kandev", "gui/501"); code != 0 {
|
||||
t.Fatalf("installLaunchd() = %d, want 0", code)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
default:
|
||||
t.Fatalf("unsupported test service manager %q", kind)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func expectedServiceHomeEntry(kind, homeDir string) string {
|
||||
if kind == nativeServiceManagerSystemd {
|
||||
return "Environment=KANDEV_HOME_DIR=" + homeDir
|
||||
}
|
||||
return "<key>KANDEV_HOME_DIR</key>\n <string>" + homeDir + "</string>"
|
||||
}
|
||||
|
||||
func restoreServiceInstallHooks(t *testing.T) {
|
||||
t.Helper()
|
||||
originalExecutablePath := executablePath
|
||||
originalExecuteServiceCommand := executeServiceCommand
|
||||
originalServicePrintln := servicePrintln
|
||||
t.Cleanup(func() {
|
||||
executablePath = originalExecutablePath
|
||||
executeServiceCommand = originalExecuteServiceCommand
|
||||
servicePrintln = originalServicePrintln
|
||||
})
|
||||
}
|
||||
|
||||
func writeServiceConfiguration(t *testing.T, path, contents string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commonconfig "github.com/kandev/kandev/internal/common/config"
|
||||
)
|
||||
|
||||
func TestRenderSystemdUnitExecsNativeKandev(t *testing.T) {
|
||||
@@ -28,6 +30,29 @@ func TestRenderSystemdUnitExecsNativeKandev(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSystemdUnitPinsSelectedConfigWithoutOverridingYAML(t *testing.T) {
|
||||
unit := renderSystemdUnit(nativeServiceUnitInput{
|
||||
Executable: "/opt/kandev/bin/kandev",
|
||||
HomeDir: "/srv/kandev",
|
||||
LogDir: "/srv/kandev/logs",
|
||||
ConfigFile: "/etc/kandev/config.yaml",
|
||||
HomeDirFromConfig: true,
|
||||
ConfigHomeFile: true,
|
||||
})
|
||||
if !strings.Contains(unit, "Environment=KANDEV_INTERNAL_CONFIG_FILE=/etc/kandev/config.yaml") {
|
||||
t.Fatalf("unit missing selected config handoff:\n%s", unit)
|
||||
}
|
||||
if !strings.Contains(unit, "Environment="+commonconfig.InternalConfigHomeFileEnv+"=1") {
|
||||
t.Fatalf("unit missing home-config handoff:\n%s", unit)
|
||||
}
|
||||
if strings.Contains(unit, "Environment=KANDEV_HOME_DIR=") {
|
||||
t.Fatalf("unit copied YAML homeDir into public environment:\n%s", unit)
|
||||
}
|
||||
if strings.Contains(unit, "Environment=KANDEV_LOG_LEVEL=info") {
|
||||
t.Fatalf("unit replaced YAML logging.level with the built-in default:\n%s", unit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSystemdUnitIncludesBundleMetadata(t *testing.T) {
|
||||
unit := renderSystemdUnit(nativeServiceUnitInput{
|
||||
Executable: "/opt/kandev/bin/kandev",
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -26,7 +28,12 @@ var (
|
||||
)
|
||||
|
||||
func runStart(ctx context.Context, opts Options) int {
|
||||
backendPort, err := resolvePorts(opts)
|
||||
startupConfig, err := loadBootstrapConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 1
|
||||
}
|
||||
backendPort, err := resolvePorts(opts, startupConfig)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 2
|
||||
@@ -36,12 +43,12 @@ func runStart(ctx context.Context, opts Options) int {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 1
|
||||
}
|
||||
if err := ensureDataDir(); err != nil {
|
||||
if err := ensureDataDirForConfig(startupConfig); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 1
|
||||
}
|
||||
|
||||
logLevel := resolveLogLevel(opts)
|
||||
logLevel := resolveLogLevelForConfig(opts, startupConfig)
|
||||
|
||||
self, err := executablePath()
|
||||
if err != nil {
|
||||
@@ -56,6 +63,7 @@ func runStart(ctx context.Context, opts Options) int {
|
||||
Ports: ports,
|
||||
LogLevel: logLevel,
|
||||
Opts: opts,
|
||||
Startup: startupConfig,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -67,12 +75,23 @@ type managedAppConfig struct {
|
||||
Ports portConfig
|
||||
LogLevel string
|
||||
Opts Options
|
||||
Startup *config.Config
|
||||
}
|
||||
|
||||
func resolveLogLevel(opts Options) string {
|
||||
func resolveLogLevel(opts Options, configs ...*config.Config) string {
|
||||
if len(configs) > 0 {
|
||||
return resolveLogLevelForConfig(opts, configs[0])
|
||||
}
|
||||
return resolveLogLevelForConfig(opts, nil)
|
||||
}
|
||||
|
||||
func resolveLogLevelForConfig(opts Options, cfg *config.Config) string {
|
||||
if logLevel := os.Getenv("KANDEV_LOG_LEVEL"); logLevel != "" {
|
||||
return logLevel
|
||||
}
|
||||
if configSourceIsExplicit(cfg, "logging.level") && cfg.Logging.Level != "" {
|
||||
return cfg.Logging.Level
|
||||
}
|
||||
switch {
|
||||
case opts.Debug:
|
||||
return "debug"
|
||||
@@ -90,7 +109,7 @@ func resolveConsoleLogLevel(opts Options) string {
|
||||
|
||||
func runManagedApp(ctx context.Context, cfg managedAppConfig) int {
|
||||
ignoreBrokenPipeSignal()
|
||||
logStartup(cfg.Header, cfg.Ports, resolveDatabasePath(), cfg.LogLevel)
|
||||
logStartup(cfg.Header, cfg.Ports, resolveDatabasePathForConfig(cfg.Startup), cfg.LogLevel, serverHostForConfig(cfg.Startup))
|
||||
setLauncherShutdownDebug(cfg.Opts.Debug || os.Getenv("KANDEV_SHUTDOWN_DEBUG") == "1")
|
||||
shutdownDebugf("runManagedApp start mode=%q backend=%q backend_cwd=%q debug=%t", cfg.Mode, cfg.Backend, cfg.BackendCWD, cfg.Opts.Debug)
|
||||
|
||||
@@ -106,7 +125,7 @@ func runManagedApp(ctx context.Context, cfg managedAppConfig) int {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 1
|
||||
}
|
||||
env := backendEnv(cfg.Ports, cfg.LogLevel, resolveConsoleLogLevel(cfg.Opts), cfg.Opts.Debug, healthToken, nil)
|
||||
env := backendEnvForConfig(cfg.Ports, cfg.LogLevel, resolveConsoleLogLevel(cfg.Opts), cfg.Opts.Debug, healthToken, nil, cfg.Startup)
|
||||
backend, dumpLogs, err := launchBackendFn(backendLaunchConfig{
|
||||
Command: cfg.Backend,
|
||||
Args: []string{"__backend"},
|
||||
@@ -115,7 +134,7 @@ func runManagedApp(ctx context.Context, cfg managedAppConfig) int {
|
||||
Quiet: false,
|
||||
Ports: cfg.Ports,
|
||||
Mode: cfg.Mode,
|
||||
HomeDir: resolveHomeDir(),
|
||||
HomeDir: resolveHomeDirForConfig(cfg.Startup),
|
||||
Supervisor: supervisor,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -124,14 +143,14 @@ func runManagedApp(ctx context.Context, cfg managedAppConfig) int {
|
||||
}
|
||||
shutdownDebugf("runManagedApp backend launched")
|
||||
fmt.Println("[kandev] starting backend...")
|
||||
if err := waitForHealthFn(ctx, cfg.Ports.BackendURL, backend, healthTimeout(healthTimeoutReleaseMS), healthToken, dumpLogs); err != nil {
|
||||
if err := waitForHealthFn(ctx, cfg.Ports.BackendURL, backend, healthTimeoutForConfig(healthTimeoutReleaseMS, cfg.Startup), healthToken, dumpLogs); err != nil {
|
||||
supervisor.shutdown("backend health failure")
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("[kandev] backend ready at %s\n", cfg.Ports.BackendURL)
|
||||
|
||||
if cfg.Opts.Headless {
|
||||
if !shouldOpenBrowser(cfg.Opts, cfg.Startup) {
|
||||
fmt.Printf("[kandev] ready (headless) at %s\n", cfg.Ports.BackendURL)
|
||||
return waitForAppExit(supervisor, backend)
|
||||
}
|
||||
@@ -140,10 +159,14 @@ func runManagedApp(ctx context.Context, cfg managedAppConfig) int {
|
||||
return waitForAppExit(supervisor, backend)
|
||||
}
|
||||
|
||||
func logStartup(header string, ports portConfig, dbPath, logLevel string) {
|
||||
func logStartup(header string, ports portConfig, dbPath, logLevel string, serverHosts ...string) {
|
||||
fmt.Println("[kandev] " + header)
|
||||
fmt.Println("[kandev] url:", ports.BackendURL)
|
||||
hosts := networkAddressesForBindHost(listHostNetworkAddresses(), os.Getenv("KANDEV_SERVER_HOST"))
|
||||
serverHost := os.Getenv("KANDEV_SERVER_HOST")
|
||||
if len(serverHosts) > 0 && serverHosts[0] != "" {
|
||||
serverHost = serverHosts[0]
|
||||
}
|
||||
hosts := networkAddressesForBindHost(listHostNetworkAddresses(), serverHost)
|
||||
for _, url := range networkURLsForPort(ports.BackendPort, hosts) {
|
||||
fmt.Println("[kandev] network:", url)
|
||||
}
|
||||
@@ -172,6 +195,23 @@ func openBrowser(url string) {
|
||||
_ = cmd.Start()
|
||||
}
|
||||
|
||||
func serverHostForConfig(cfg *config.Config) string {
|
||||
if cfg != nil && configSourceIsExplicit(cfg, "server.host") {
|
||||
return cfg.Server.Host
|
||||
}
|
||||
return os.Getenv("KANDEV_SERVER_HOST")
|
||||
}
|
||||
|
||||
func shouldOpenBrowser(opts Options, cfg *config.Config) bool {
|
||||
if opts.Headless {
|
||||
return false
|
||||
}
|
||||
if cfg != nil {
|
||||
return !cfg.Launcher.NoBrowser
|
||||
}
|
||||
return os.Getenv("KANDEV_NO_BROWSER") != "1"
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -23,19 +21,20 @@ const DefaultTickInterval = 5 * time.Second
|
||||
// if no agent lifecycle event returned it to a terminal queue state.
|
||||
const staleClaimedRunAge = 30 * time.Minute
|
||||
|
||||
// TickIntervalFromEnv reads KANDEV_OFFICE_SCHEDULER_TICK_MS and returns the
|
||||
// corresponding duration. Falls back to DefaultTickInterval when the variable
|
||||
// is unset or invalid.
|
||||
// TickIntervalFromConfig converts the resolved typed millisecond setting into
|
||||
// the office scheduler duration. Invalid values retain the safe default.
|
||||
func TickIntervalFromConfig(milliseconds int) time.Duration {
|
||||
if milliseconds <= 0 {
|
||||
return DefaultTickInterval
|
||||
}
|
||||
return time.Duration(milliseconds) * time.Millisecond
|
||||
}
|
||||
|
||||
// TickIntervalFromEnv remains as a compatibility helper for package callers.
|
||||
// Backend startup resolves the setting through common/config and passes the
|
||||
// result to NewSchedulerIntegration.
|
||||
func TickIntervalFromEnv() time.Duration {
|
||||
raw := os.Getenv("KANDEV_OFFICE_SCHEDULER_TICK_MS")
|
||||
if raw == "" {
|
||||
return DefaultTickInterval
|
||||
}
|
||||
ms, err := strconv.Atoi(raw)
|
||||
if err != nil || ms <= 0 {
|
||||
return DefaultTickInterval
|
||||
}
|
||||
return time.Duration(ms) * time.Millisecond
|
||||
return DefaultTickInterval
|
||||
}
|
||||
|
||||
// SchedulerIntegration runs the run processing tick loop.
|
||||
|
||||
@@ -94,35 +94,52 @@ type profilesFile map[string]map[string]map[string]string
|
||||
// already set.
|
||||
func ApplyProfile() (count int, env Environment, err error) {
|
||||
env = DetectEnvironment()
|
||||
file, err := parse(profilesYAML)
|
||||
defaults, err := EnvironmentDefaults()
|
||||
if err != nil {
|
||||
return 0, env, err
|
||||
}
|
||||
for _, vars := range file {
|
||||
for name, perProfile := range vars {
|
||||
value := resolve(perProfile, env)
|
||||
if value == "" {
|
||||
// Empty means "leave unset". Skip even if the var is
|
||||
// also currently unset — we don't want to introduce a
|
||||
// noisy empty-string entry that os.LookupEnv would
|
||||
// then report as set.
|
||||
continue
|
||||
}
|
||||
if _, alreadySet := os.LookupEnv(name); alreadySet {
|
||||
continue
|
||||
}
|
||||
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++
|
||||
for name, value := range defaults {
|
||||
if value == "" {
|
||||
// Empty means "leave unset". Skip even if the var is
|
||||
// also currently unset — we don't want to introduce a
|
||||
// noisy empty-string entry that os.LookupEnv would
|
||||
// then report as set.
|
||||
continue
|
||||
}
|
||||
if _, alreadySet := os.LookupEnv(name); alreadySet {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// EnvironmentDefaults returns the resolved profile values without mutating the
|
||||
// process environment. Startup configuration uses this projection as typed
|
||||
// defaults so a selected config.yaml can override a profile value. Callers
|
||||
// that need the historical child-process environment should still call
|
||||
// ApplyProfile.
|
||||
func EnvironmentDefaults() (map[string]string, error) {
|
||||
file, err := parse(profilesYAML)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := DetectEnvironment()
|
||||
out := make(map[string]string)
|
||||
for _, vars := range file {
|
||||
for name, perProfile := range vars {
|
||||
out[name] = resolve(perProfile, env)
|
||||
}
|
||||
}
|
||||
return out, 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
|
||||
|
||||
@@ -54,6 +54,37 @@ func TestApplyProfile_DefaultsToProd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentDefaultsSelectsProfileWithoutMutatingEnvironment(t *testing.T) {
|
||||
clearProfileSelectors(t)
|
||||
clearProfilesYAMLVars(t)
|
||||
t.Setenv("KANDEV_FEATURES_OFFICE", "explicit")
|
||||
|
||||
prodBefore, prodPresent := os.LookupEnv("KANDEV_FEATURES_OFFICE")
|
||||
prod, err := EnvironmentDefaults()
|
||||
if err != nil {
|
||||
t.Fatalf("prod EnvironmentDefaults: %v", err)
|
||||
}
|
||||
if prod["KANDEV_FEATURES_OFFICE"] != "false" {
|
||||
t.Fatalf("prod office default = %q, want false", prod["KANDEV_FEATURES_OFFICE"])
|
||||
}
|
||||
prodAfter, prodAfterPresent := os.LookupEnv("KANDEV_FEATURES_OFFICE")
|
||||
if prodPresent != prodAfterPresent || prodBefore != prodAfter {
|
||||
t.Fatalf("prod EnvironmentDefaults mutated KANDEV_FEATURES_OFFICE: before=%q/%v after=%q/%v", prodBefore, prodPresent, prodAfter, prodAfterPresent)
|
||||
}
|
||||
|
||||
t.Setenv("KANDEV_E2E_MOCK", "true")
|
||||
e2e, err := EnvironmentDefaults()
|
||||
if err != nil {
|
||||
t.Fatalf("e2e EnvironmentDefaults: %v", err)
|
||||
}
|
||||
if e2e["KANDEV_FEATURES_OFFICE"] != "true" {
|
||||
t.Fatalf("e2e office default = %q, want true", e2e["KANDEV_FEATURES_OFFICE"])
|
||||
}
|
||||
if e2e["KANDEV_PLAN_COALESCE_WINDOW_MS"] != "2000" {
|
||||
t.Fatalf("e2e plan coalesce default = %q, want 2000", e2e["KANDEV_PLAN_COALESCE_WINDOW_MS"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyProfile_DevUsesDevelopmentDefaults verifies the mixed dev profile.
|
||||
func TestApplyProfile_DevUsesDevelopmentDefaults(t *testing.T) {
|
||||
clearProfileSelectors(t)
|
||||
|
||||
@@ -17,8 +17,6 @@ package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -42,21 +40,20 @@ const DefaultTickInterval = 5 * time.Second
|
||||
// has elapsed.
|
||||
const SignalCoalesceWindow = 10 * time.Millisecond
|
||||
|
||||
// TickIntervalFromEnv reads KANDEV_OFFICE_SCHEDULER_TICK_MS and returns
|
||||
// the corresponding duration. Falls back to DefaultTickInterval when
|
||||
// the variable is unset or invalid. The env var name keeps the
|
||||
// "office" prefix for backward compatibility with existing
|
||||
// deployments.
|
||||
// TickIntervalFromConfig converts the resolved typed millisecond setting into
|
||||
// the scheduler duration. Invalid values retain the safe default.
|
||||
func TickIntervalFromConfig(milliseconds int) time.Duration {
|
||||
if milliseconds <= 0 {
|
||||
return DefaultTickInterval
|
||||
}
|
||||
return time.Duration(milliseconds) * time.Millisecond
|
||||
}
|
||||
|
||||
// TickIntervalFromEnv remains as a compatibility helper for package callers.
|
||||
// Backend startup resolves the setting through common/config and passes the
|
||||
// result to New, so this helper no longer performs a package-local env read.
|
||||
func TickIntervalFromEnv() time.Duration {
|
||||
raw := os.Getenv("KANDEV_OFFICE_SCHEDULER_TICK_MS")
|
||||
if raw == "" {
|
||||
return DefaultTickInterval
|
||||
}
|
||||
ms, err := strconv.Atoi(raw)
|
||||
if err != nil || ms <= 0 {
|
||||
return DefaultTickInterval
|
||||
}
|
||||
return time.Duration(ms) * time.Millisecond
|
||||
return DefaultTickInterval
|
||||
}
|
||||
|
||||
// RunProcessor performs one claim-and-dispatch pass. The scheduler
|
||||
|
||||
@@ -42,6 +42,8 @@ func handleUpdate(service *Service) gin.HandlerFunc {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: err.Error()})
|
||||
case errors.Is(err, ErrEnvironmentLocked):
|
||||
ctx.JSON(http.StatusConflict, gin.H{responseErrorKey: err.Error()})
|
||||
case errors.Is(err, ErrConfigurationLocked):
|
||||
ctx.JSON(http.StatusConflict, gin.H{responseErrorKey: err.Error()})
|
||||
default:
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "failed to save message queue settings"})
|
||||
}
|
||||
|
||||
@@ -59,6 +59,29 @@ func TestResolveRejectsNegativePersistedValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigurationPrecedence(t *testing.T) {
|
||||
configured := &Settings{MaxPerSession: 6, MergeEnabled: true, AutoMergeEnabled: true}
|
||||
startup := Configuration{Value: 14, Present: true}
|
||||
|
||||
resolution, err := Resolve(configured, Environment{}, startup)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve configuration: %v", err)
|
||||
}
|
||||
if resolution.Effective.MaxPerSession != 14 ||
|
||||
resolution.Effective.Source != SourceConfiguration ||
|
||||
!resolution.Effective.Locked {
|
||||
t.Fatalf("configuration resolution = %+v, want locked configuration value", resolution.Effective)
|
||||
}
|
||||
|
||||
resolution, err = Resolve(configured, Environment{Value: "20", Present: true}, startup)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve environment over configuration: %v", err)
|
||||
}
|
||||
if resolution.Effective.MaxPerSession != 20 || resolution.Effective.Source != SourceEnvironment {
|
||||
t.Fatalf("environment resolution = %+v, want environment value", resolution.Effective)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRoundTripAndValidation(t *testing.T) {
|
||||
raw := &fakeRawStore{}
|
||||
store := NewStore(raw)
|
||||
@@ -311,6 +334,32 @@ func TestServiceEnvironmentLockRejectsUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceConfigurationLockRejectsUpdate(t *testing.T) {
|
||||
raw := &fakeRawStore{}
|
||||
target := &fakeTarget{max: 14}
|
||||
service := NewService(
|
||||
NewStore(raw), target, func() Environment { return Environment{} }, testLogger(t),
|
||||
Configuration{Value: 14, Present: true},
|
||||
)
|
||||
|
||||
response, err := service.Get(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if response.Effective.Source != SourceConfiguration || !response.Effective.Locked ||
|
||||
response.Effective.MaxPerSession != 14 {
|
||||
t.Fatalf("configuration response = %+v, want locked configuration source", response)
|
||||
}
|
||||
|
||||
_, err = service.Update(context.Background(), SettingsPatch{MaxPerSession: new(4)})
|
||||
if !errors.Is(err, ErrConfigurationLocked) {
|
||||
t.Fatalf("update error = %v, want configuration lock", err)
|
||||
}
|
||||
if raw.saveCalls != 0 || target.max != 14 {
|
||||
t.Fatalf("locked configuration update mutated state: saves=%d target=%d", raw.saveCalls, target.max)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceEnvironmentLockAllowsMergeOnlyUpdate asserts the environment
|
||||
// lock only blocks a patch that touches max_per_session — merge_enabled has
|
||||
// no environment override and must stay editable.
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
package queuesettings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Resolve combines persisted configuration with an optional environment
|
||||
// override. A malformed environment value is reported but ignored so the
|
||||
// persisted/default setting remains effective.
|
||||
func Resolve(configured *Settings, environment Environment) (Resolution, error) {
|
||||
// Resolve combines persisted settings with startup configuration and an
|
||||
// optional environment override. A malformed environment value is reported
|
||||
// but ignored so the configuration/persisted/default setting remains
|
||||
// effective. Environment takes precedence over startup configuration, which
|
||||
// takes precedence over the persisted setting.
|
||||
func Resolve(configured *Settings, environment Environment, startup ...Configuration) (Resolution, error) {
|
||||
settings := DefaultSettings()
|
||||
source := SourceDefault
|
||||
var configuration Configuration
|
||||
if len(startup) > 0 {
|
||||
configuration = startup[0]
|
||||
}
|
||||
if configuration.Present && configuration.Value < 0 {
|
||||
return Resolution{}, fmt.Errorf("%w: max_per_session must be zero or greater", ErrValidation)
|
||||
}
|
||||
if configured != nil {
|
||||
if err := Validate(*configured); err != nil {
|
||||
return Resolution{}, err
|
||||
@@ -29,12 +39,12 @@ func Resolve(configured *Settings, environment Environment) (Resolution, error)
|
||||
}}
|
||||
raw := strings.TrimSpace(environment.Value)
|
||||
if !environment.Present || raw == "" {
|
||||
return resolution, nil
|
||||
return resolveConfiguration(resolution, configuration), nil
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
resolution.InvalidEnvironment = true
|
||||
return resolution, nil
|
||||
return resolveConfiguration(resolution, configuration), nil
|
||||
}
|
||||
if value <= 0 {
|
||||
value = 0
|
||||
@@ -48,3 +58,17 @@ func Resolve(configured *Settings, environment Environment) (Resolution, error)
|
||||
}
|
||||
return resolution, nil
|
||||
}
|
||||
|
||||
func resolveConfiguration(resolution Resolution, configuration Configuration) Resolution {
|
||||
if !configuration.Present {
|
||||
return resolution
|
||||
}
|
||||
resolution.Effective = Effective{
|
||||
MaxPerSession: configuration.Value,
|
||||
Source: SourceConfiguration,
|
||||
Locked: true,
|
||||
MergeEnabled: resolution.Settings.MergeEnabled,
|
||||
AutoMergeEnabled: resolution.Settings.AutoMergeEnabled,
|
||||
}
|
||||
return resolution
|
||||
}
|
||||
|
||||
@@ -26,14 +26,28 @@ type Service struct {
|
||||
store *Store
|
||||
target Target
|
||||
readEnvironment EnvironmentReader
|
||||
configuration Configuration
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
func NewService(store *Store, target Target, readEnvironment EnvironmentReader, log *logger.Logger) *Service {
|
||||
func NewService(
|
||||
store *Store,
|
||||
target Target,
|
||||
readEnvironment EnvironmentReader,
|
||||
log *logger.Logger,
|
||||
startup ...Configuration,
|
||||
) *Service {
|
||||
if readEnvironment == nil {
|
||||
readEnvironment = ReadEnvironment
|
||||
}
|
||||
return &Service{store: store, target: target, readEnvironment: readEnvironment, logger: log}
|
||||
var configuration Configuration
|
||||
if len(startup) > 0 {
|
||||
configuration = startup[0]
|
||||
}
|
||||
return &Service{
|
||||
store: store, target: target, readEnvironment: readEnvironment,
|
||||
configuration: configuration, logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
func ReadEnvironment() Environment {
|
||||
@@ -49,7 +63,7 @@ func (s *Service) Get(ctx context.Context) (Response, error) {
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
resolution, err := Resolve(configured, s.readEnvironment())
|
||||
resolution, err := Resolve(configured, s.readEnvironment(), s.configuration)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
@@ -71,12 +85,15 @@ func (s *Service) Update(ctx context.Context, patch SettingsPatch) (Response, er
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
resolution, err := Resolve(current, environment)
|
||||
resolution, err := Resolve(current, environment, s.configuration)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
s.warnInvalidEnvironment(resolution)
|
||||
if patch.MaxPerSession != nil && resolution.Effective.Locked {
|
||||
if resolution.Effective.Source == SourceConfiguration {
|
||||
return Response{}, ErrConfigurationLocked
|
||||
}
|
||||
return Response{}, ErrEnvironmentLocked
|
||||
}
|
||||
settings := patch.Apply(resolution.Settings)
|
||||
@@ -89,7 +106,7 @@ func (s *Service) Update(ctx context.Context, patch SettingsPatch) (Response, er
|
||||
if err := s.store.Save(ctx, settings); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
updated, err := Resolve(&settings, environment)
|
||||
updated, err := Resolve(&settings, environment, s.configuration)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
|
||||
@@ -16,16 +16,18 @@ const (
|
||||
type Source string
|
||||
|
||||
const (
|
||||
SourceDefault Source = "default"
|
||||
SourceSetting Source = "setting"
|
||||
SourceEnvironment Source = "environment"
|
||||
SourceDefault Source = "default"
|
||||
SourceSetting Source = "setting"
|
||||
SourceConfiguration Source = "configuration"
|
||||
SourceEnvironment Source = "environment"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrValidation = errors.New("message queue settings validation")
|
||||
ErrInvalidPersisted = errors.New("invalid persisted message queue settings")
|
||||
ErrEnvironmentLocked = errors.New("message queue capacity is controlled by the environment")
|
||||
ErrTargetUnavailable = errors.New("message queue live target is unavailable")
|
||||
ErrValidation = errors.New("message queue settings validation")
|
||||
ErrInvalidPersisted = errors.New("invalid persisted message queue settings")
|
||||
ErrEnvironmentLocked = errors.New("message queue capacity is controlled by the environment")
|
||||
ErrConfigurationLocked = errors.New("message queue capacity is controlled by configuration")
|
||||
ErrTargetUnavailable = errors.New("message queue live target is unavailable")
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
@@ -81,6 +83,13 @@ type Environment struct {
|
||||
Present bool
|
||||
}
|
||||
|
||||
// Configuration is the startup value for max_per_session. Present is kept
|
||||
// separate from Value because zero is a valid, explicit unlimited setting.
|
||||
type Configuration struct {
|
||||
Value int
|
||||
Present bool
|
||||
}
|
||||
|
||||
type Resolution struct {
|
||||
Response
|
||||
InvalidEnvironment bool
|
||||
|
||||
@@ -59,6 +59,7 @@ type Wiring struct {
|
||||
OrchestratorShutdown func()
|
||||
RestoreQuiesce func() error
|
||||
MessageQueue queuesettings.Target
|
||||
MessageQueueConfig queuesettings.Configuration
|
||||
TaskSessions sleepinhibition.SessionReader
|
||||
}
|
||||
|
||||
@@ -125,6 +126,7 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus
|
||||
if wiring.MessageQueue != nil {
|
||||
queueSettingsSvc = queuesettings.NewService(
|
||||
queuesettings.NewStore(settingsStore), wiring.MessageQueue, nil, log,
|
||||
wiring.MessageQueueConfig,
|
||||
)
|
||||
}
|
||||
if wiring.TaskSessions != nil {
|
||||
|
||||
@@ -3,8 +3,6 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -31,7 +29,6 @@ const (
|
||||
createdByAgent = "agent"
|
||||
createdByUser = "user"
|
||||
defaultCoalesceWindow = 5 * time.Minute
|
||||
coalesceWindowEnvVar = "KANDEV_PLAN_COALESCE_WINDOW_MS"
|
||||
defaultAgentAuthorFallback = "Agent"
|
||||
defaultUserAuthorFallback = "User"
|
||||
)
|
||||
@@ -61,12 +58,17 @@ type PlanService struct {
|
||||
// NewPlanService creates a new task plan service. The concrete repository
|
||||
// passed by callers must implement both PlanRepository and the session-lookup
|
||||
// methods on planRepo (the SQLite repository does both).
|
||||
func NewPlanService(repo planRepo, eventBus bus.EventBus, log *logger.Logger) *PlanService {
|
||||
|
||||
func NewPlanService(repo planRepo, eventBus bus.EventBus, log *logger.Logger, configuredWindow ...time.Duration) *PlanService {
|
||||
coalesceWindow := defaultCoalesceWindow
|
||||
if len(configuredWindow) > 0 {
|
||||
coalesceWindow = resolveCoalesceWindow(configuredWindow[0])
|
||||
}
|
||||
return &PlanService{
|
||||
repo: repo,
|
||||
eventBus: eventBus,
|
||||
logger: log.WithFields(zap.String("component", "plan-service")),
|
||||
coalesceWindow: readCoalesceWindow(),
|
||||
coalesceWindow: coalesceWindow,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,16 +85,11 @@ func (s *PlanService) authorize(ctx context.Context, taskID string) error {
|
||||
return s.authorizeTask(ctx, taskID)
|
||||
}
|
||||
|
||||
func readCoalesceWindow() time.Duration {
|
||||
raw := os.Getenv(coalesceWindowEnvVar)
|
||||
if raw == "" {
|
||||
func resolveCoalesceWindow(window time.Duration) time.Duration {
|
||||
if window < 0 {
|
||||
return defaultCoalesceWindow
|
||||
}
|
||||
ms, err := strconv.Atoi(raw)
|
||||
if err != nil || ms < 0 {
|
||||
return defaultCoalesceWindow
|
||||
}
|
||||
return time.Duration(ms) * time.Millisecond
|
||||
return window
|
||||
}
|
||||
|
||||
// CreatePlanRequest contains parameters for creating/updating a task plan.
|
||||
|
||||
@@ -51,6 +51,12 @@ func createTestPlanService(t *testing.T) (*PlanService, *MockEventBus, *sqlitere
|
||||
return svc, eventBus, repo
|
||||
}
|
||||
|
||||
func TestResolveCoalesceWindowUsesTypedStartupValue(t *testing.T) {
|
||||
if got := resolveCoalesceWindow(2400 * time.Millisecond); got != 2400*time.Millisecond {
|
||||
t.Fatalf("coalesce window = %s, want 2.4s", got)
|
||||
}
|
||||
}
|
||||
|
||||
type nilMarkPlanRepo struct {
|
||||
*sqliterepo.Repository
|
||||
}
|
||||
|
||||
@@ -731,6 +731,24 @@ func (r *Repository) recordStepDecisionTx(ctx context.Context, d *models.Workflo
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// PostgreSQL's READ COMMITTED isolation does not serialize the initial
|
||||
// supersede UPDATE when no active row exists yet. Lock the identity before
|
||||
// that UPDATE so concurrent writers observe the prior commit instead of
|
||||
// racing into the partial unique index. SQLite's single-writer lock already
|
||||
// provides this serialization.
|
||||
lockIdentity := d.ParticipantID
|
||||
if d.DeciderID != "" && d.Role != "" {
|
||||
lockIdentity = d.DeciderID + "\x1f" + d.Role
|
||||
}
|
||||
if dialect.IsPostgres(r.db.DriverName()) {
|
||||
lockKey := "workflow-step-decision:" + d.TaskID + "\x1f" + d.StepID + "\x1f" + lockIdentity
|
||||
if _, err := tx.ExecContext(ctx, tx.Rebind(`
|
||||
SELECT pg_advisory_xact_lock(hashtextextended(?, 0))
|
||||
`), lockKey); err != nil {
|
||||
return fmt.Errorf("lock step decision identity: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if d.DeciderID != "" && d.Role != "" {
|
||||
if _, err := tx.ExecContext(ctx, tx.Rebind(`
|
||||
UPDATE workflow_step_decisions
|
||||
|
||||
@@ -196,6 +196,25 @@ describe("MessageQueueSettings — per-session limit", () => {
|
||||
expect(screen.getByTestId("message-queue-source").textContent).toBe("Environment");
|
||||
expect(screen.getByText(/KANDEV_QUEUE_MAX_PER_SESSION/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a configuration value as a locked configuration source", async () => {
|
||||
fetchSettingsMock.mockResolvedValueOnce(
|
||||
response({
|
||||
configured: 25,
|
||||
effective: 41,
|
||||
source: "configuration" as MessageQueueSettingsResponse["effective"]["source"],
|
||||
locked: true,
|
||||
}),
|
||||
);
|
||||
render(<MessageQueueSettings />);
|
||||
|
||||
const input = await screen.findByLabelText(MAXIMUM_LABEL);
|
||||
expect(input).toHaveProperty("disabled", true);
|
||||
expect(screen.getByTestId(EFFECTIVE_VALUE_ID).textContent).toBe("41");
|
||||
expect(screen.getByTestId("message-queue-source").textContent).toBe("Configuration");
|
||||
expect(screen.getByText(/Managed by configuration/)).toBeTruthy();
|
||||
expect(screen.queryByText(/KANDEV_QUEUE_MAX_PER_SESSION/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MessageQueueSettings — permissions and recovery", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Label } from "@kandev/ui/label";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { Switch } from "@kandev/ui/switch";
|
||||
import { IconAlertCircle, IconInfoCircle, IconLock } from "@tabler/icons-react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
@@ -36,6 +37,8 @@ function sourceLabelKey(source: MessageQueueSettingsSource): string {
|
||||
switch (source) {
|
||||
case "setting":
|
||||
return "system:messageQueueSourceSetting";
|
||||
case "configuration":
|
||||
return "system:messageQueueSourceConfiguration";
|
||||
case "environment":
|
||||
return "system:messageQueueSourceEnvironment";
|
||||
default:
|
||||
@@ -43,6 +46,32 @@ function sourceLabelKey(source: MessageQueueSettingsSource): string {
|
||||
}
|
||||
}
|
||||
|
||||
function queueCapacityLockMessage(
|
||||
t: TFunction,
|
||||
source: MessageQueueSettingsSource | undefined,
|
||||
): string {
|
||||
if (source === "configuration") return t("system:messageQueueConfigurationLocked");
|
||||
return t("system:messageQueueEnvironmentLocked", { variable: ENVIRONMENT_VARIABLE });
|
||||
}
|
||||
|
||||
type QueueSettingsValidationOptions = {
|
||||
parsed: number | null;
|
||||
isAdmin: boolean;
|
||||
isLocked: boolean;
|
||||
isMaxDirty: boolean;
|
||||
lockSource: MessageQueueSettingsSource | undefined;
|
||||
};
|
||||
|
||||
function queueSettingsInvalidReason(
|
||||
t: TFunction,
|
||||
{ parsed, isAdmin, isLocked, isMaxDirty, lockSource }: QueueSettingsValidationOptions,
|
||||
): string | undefined {
|
||||
if (parsed === null) return t("system:messageQueueValidation");
|
||||
if (!isAdmin) return t("system:messageQueueAdminOnly");
|
||||
if (isLocked && isMaxDirty) return queueCapacityLockMessage(t, lockSource);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Loads the persisted snapshot and owns the editable queue-setting drafts.
|
||||
* Split out of
|
||||
* `useMessageQueueSettingsDraft` so neither function needs to grow past the
|
||||
@@ -194,12 +223,14 @@ function useMessageQueueSettingsDraft() {
|
||||
const isAutoMergeDirty = autoMergeBaseline !== undefined && autoMergeDraft !== autoMergeBaseline;
|
||||
const isAdmin = role === undefined || role === "admin";
|
||||
const isLocked = snapshot?.effective.locked === true;
|
||||
let invalidReason: string | undefined;
|
||||
if (parsed === null) invalidReason = t("system:messageQueueValidation");
|
||||
else if (!isAdmin) invalidReason = t("system:messageQueueAdminOnly");
|
||||
else if (isLocked && isMaxDirty) {
|
||||
invalidReason = t("system:messageQueueEnvironmentLocked", { variable: ENVIRONMENT_VARIABLE });
|
||||
}
|
||||
const lockSource = snapshot?.effective.source;
|
||||
const invalidReason = queueSettingsInvalidReason(t, {
|
||||
parsed,
|
||||
isAdmin,
|
||||
isLocked,
|
||||
isMaxDirty,
|
||||
lockSource,
|
||||
});
|
||||
|
||||
useQueueSettingsContributor({
|
||||
load,
|
||||
@@ -227,6 +258,7 @@ function useMessageQueueSettingsDraft() {
|
||||
isDirty: isMaxDirty || isMergeDirty || isAutoMergeDirty,
|
||||
isAdmin,
|
||||
isLocked,
|
||||
lockSource,
|
||||
reload: load.reload,
|
||||
};
|
||||
}
|
||||
@@ -392,6 +424,7 @@ export function MessageQueueSettings() {
|
||||
|
||||
if (!state.snapshot) return null;
|
||||
const { settings, effective } = state.snapshot;
|
||||
const isConfigurationLocked = state.lockSource === "configuration";
|
||||
const effectiveValue =
|
||||
effective.max_per_session === 0
|
||||
? t("system:messageQueueUnlimited")
|
||||
@@ -420,10 +453,14 @@ export function MessageQueueSettings() {
|
||||
{state.isLocked && (
|
||||
<Alert>
|
||||
<IconLock className="size-4" />
|
||||
<AlertTitle>{t("system:messageQueueEnvironmentLockTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("system:messageQueueEnvironmentLocked", { variable: ENVIRONMENT_VARIABLE })}
|
||||
</AlertDescription>
|
||||
<AlertTitle>
|
||||
{t(
|
||||
isConfigurationLocked
|
||||
? "system:messageQueueConfigurationLockTitle"
|
||||
: "system:messageQueueEnvironmentLockTitle",
|
||||
)}
|
||||
</AlertTitle>
|
||||
<AlertDescription>{queueCapacityLockMessage(t, state.lockSource)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!state.isAdmin && (
|
||||
|
||||
@@ -172,6 +172,39 @@ test.describe.serial("Message Queue task behavior settings", () => {
|
||||
await expect(testPage.getByTestId("message-queue-auto-merge-enabled")).toBeEnabled();
|
||||
});
|
||||
|
||||
test("configuration override is visible and read-only", async ({ testPage }) => {
|
||||
await testPage.route(`**${MESSAGE_QUEUE_SETTINGS_PATH}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
settings: {
|
||||
max_per_session: baseline?.max_per_session ?? 10,
|
||||
merge_enabled: true,
|
||||
auto_merge_enabled: true,
|
||||
},
|
||||
effective: {
|
||||
max_per_session: 43,
|
||||
source: "configuration",
|
||||
locked: true,
|
||||
merge_enabled: true,
|
||||
auto_merge_enabled: true,
|
||||
},
|
||||
} satisfies MessageQueueSettingsResponse),
|
||||
});
|
||||
});
|
||||
|
||||
await testPage.goto("/settings/preferences/task-behavior");
|
||||
await expect(testPage.getByLabel("Maximum messages per session")).toBeDisabled();
|
||||
await expect(testPage.getByTestId("message-queue-effective-value")).toHaveText("43");
|
||||
await expect(testPage.getByTestId("message-queue-source")).toHaveText("Configuration");
|
||||
await expect(testPage.getByText(/Managed by configuration/)).toBeVisible();
|
||||
await expect(testPage.getByText(/KANDEV_QUEUE_MAX_PER_SESSION/)).toHaveCount(0);
|
||||
await expect(testPage.getByTestId("settings-floating-save")).toHaveCount(0);
|
||||
await expect(testPage.getByTestId("message-queue-merge-enabled")).toBeEnabled();
|
||||
await expect(testPage.getByTestId("message-queue-auto-merge-enabled")).toBeEnabled();
|
||||
});
|
||||
|
||||
test("member can navigate to the setting but cannot edit it", async ({ testPage }) => {
|
||||
await testPage.goto("/settings/preferences/appearance");
|
||||
await testPage.waitForFunction(() => Boolean(window.__KANDEV_E2E_STORE__));
|
||||
|
||||
@@ -88,3 +88,49 @@ test("mobile navigation reaches the Message Queue section with touch-safe shared
|
||||
String(expected),
|
||||
);
|
||||
});
|
||||
|
||||
test("mobile configuration lock keeps the source and accessible controls consistent", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
await testPage.route(`**${MESSAGE_QUEUE_SETTINGS_PATH}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
settings: {
|
||||
max_per_session: baseline?.max_per_session ?? 10,
|
||||
merge_enabled: true,
|
||||
auto_merge_enabled: true,
|
||||
},
|
||||
effective: {
|
||||
max_per_session: 44,
|
||||
source: "configuration",
|
||||
locked: true,
|
||||
merge_enabled: true,
|
||||
auto_merge_enabled: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const mobile = new MobileKanbanPage(testPage);
|
||||
await mobile.goto();
|
||||
await mobile.mobileMenuButton.click();
|
||||
await testPage
|
||||
.getByTestId("mobile-home-menu-card")
|
||||
.getByRole("link", { name: "Settings" })
|
||||
.click();
|
||||
await testPage
|
||||
.getByTestId("settings-index")
|
||||
.getByRole("link", { name: /^Task Behavior/ })
|
||||
.click();
|
||||
|
||||
const input = testPage.getByLabel("Maximum messages per session");
|
||||
await expect(input).toBeDisabled();
|
||||
await expect(testPage.getByTestId("message-queue-source")).toHaveText("Configuration");
|
||||
await expect(testPage.getByText(/Managed by configuration/)).toBeVisible();
|
||||
await expect(testPage.getByText(/KANDEV_QUEUE_MAX_PER_SESSION/)).toHaveCount(0);
|
||||
const inputBox = await input.boundingBox();
|
||||
expect(inputBox).not.toBeNull();
|
||||
expect(inputBox!.height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
|
||||
@@ -195,7 +195,7 @@ export interface MessageQueueSettingsValue {
|
||||
/** Partial PATCH payload: omitted fields are left unchanged server-side. */
|
||||
export type MessageQueueSettingsPatch = Partial<MessageQueueSettingsValue>;
|
||||
|
||||
export type MessageQueueSettingsSource = "default" | "setting" | "environment";
|
||||
export type MessageQueueSettingsSource = "default" | "setting" | "configuration" | "environment";
|
||||
|
||||
export interface MessageQueueEffectiveSettings extends MessageQueueSettingsValue {
|
||||
source: MessageQueueSettingsSource;
|
||||
|
||||
@@ -171,6 +171,8 @@
|
||||
"messageQueueDescription": "Control how many pending messages each session can hold before new work is rejected.",
|
||||
"messageQueueEffective": "Effective:",
|
||||
"messageQueueEffectiveHelp": "The effective value is used for new admissions now. Existing queue entries are not pruned when this value is lowered.",
|
||||
"messageQueueConfigurationLockTitle": "Managed by configuration",
|
||||
"messageQueueConfigurationLocked": "The YAML configuration value overrides the saved setting. Change the value in config.yaml and restart Kandev to edit it here again.",
|
||||
"messageQueueEnvironmentLockTitle": "Managed by environment",
|
||||
"messageQueueEnvironmentLocked": "{{variable}} overrides the saved setting. Change the environment value and restart Kandev to edit it here again.",
|
||||
"messageQueueLimitDescription": "This limit applies immediately to newly admitted messages. Lowering it never removes messages already waiting, and accepted delivery retries remain safe.",
|
||||
@@ -184,6 +186,7 @@
|
||||
"messageQueueRetry": "Retry",
|
||||
"messageQueueSaveFailed": "Failed to save message queue settings.",
|
||||
"messageQueueSourceDefault": "Default",
|
||||
"messageQueueSourceConfiguration": "Configuration",
|
||||
"messageQueueSourceEnvironment": "Environment",
|
||||
"messageQueueSourceSetting": "Saved setting",
|
||||
"messageQueueTitle": "Message Queue",
|
||||
|
||||
@@ -171,6 +171,8 @@
|
||||
"messageQueueDescription": "Ćōńţŕōĺ ĥōŵ ḿàńŷ ƥēńďĩńĝ ḿēśśàĝēś ēàćĥ śēśśĩōń ćàń ĥōĺď ƀēƒōŕē ńēŵ ŵōŕķ ĩś ŕēĵēćţēď.",
|
||||
"messageQueueEffective": "Ēƒƒēćţĩvē:",
|
||||
"messageQueueEffectiveHelp": "Ţĥē ēƒƒēćţĩvē vàĺũē ĩś ũśēď ƒōŕ ńēŵ àďḿĩśśĩōńś ńōŵ. Ēxĩśţĩńĝ qũēũē ēńţŕĩēś àŕē ńōţ ƥŕũńēď ŵĥēń ţĥĩś vàĺũē ĩś ĺōŵēŕēď.",
|
||||
"messageQueueConfigurationLockTitle": "Ḿàńàĝēď ƀŷ ćōńƒĩĝũŕàţĩōń",
|
||||
"messageQueueConfigurationLocked": "Ţĥē ŶÀḾĹ ćōńƒĩĝũŕàţĩōń vàĺũē ōvēŕŕĩďēś ţĥē śàvēď śēţţĩńĝ. Ćĥàńĝē ţĥē vàĺũē ĩń ćōńƒĩĝ.ŷàḿĺ àńď ŕēśţàŕţ Ķàńďēv ţō ēďĩţ ĩţ ĥēŕē àĝàĩń.",
|
||||
"messageQueueEnvironmentLockTitle": "Ḿàńàĝēď ƀŷ ēńvĩŕōńḿēńţ",
|
||||
"messageQueueEnvironmentLocked": "{{variable}} ōvēŕŕĩďēś ţĥē śàvēď śēţţĩńĝ. Ćĥàńĝē ţĥē ēńvĩŕōńḿēńţ vàĺũē àńď ŕēśţàŕţ Ķàńďēv ţō ēďĩţ ĩţ ĥēŕē àĝàĩń.",
|
||||
"messageQueueLimitDescription": "Ţĥĩś ĺĩḿĩţ àƥƥĺĩēś ĩḿḿēďĩàţēĺŷ ţō ńēŵĺŷ àďḿĩţţēď ḿēśśàĝēś. Ĺōŵēŕĩńĝ ĩţ ńēvēŕ ŕēḿōvēś ḿēśśàĝēś àĺŕēàďŷ ŵàĩţĩńĝ, àńď àććēƥţēď ďēĺĩvēŕŷ ŕēţŕĩēś ŕēḿàĩń śàƒē.",
|
||||
@@ -184,6 +186,7 @@
|
||||
"messageQueueRetry": "Ŕēţŕŷ",
|
||||
"messageQueueSaveFailed": "Ƒàĩĺēď ţō śàvē ḿēśśàĝē qũēũē śēţţĩńĝś.",
|
||||
"messageQueueSourceDefault": "Ďēƒàũĺţ",
|
||||
"messageQueueSourceConfiguration": "Ćōńƒĩĝũŕàţĩōń",
|
||||
"messageQueueSourceEnvironment": "Ēńvĩŕōńḿēńţ",
|
||||
"messageQueueSourceSetting": "Śàvēď śēţţĩńĝ",
|
||||
"messageQueueTitle": "Ḿēśśàĝē Qũēũē",
|
||||
|
||||
@@ -167,6 +167,8 @@
|
||||
"messageQueueDescription": "Controle quantas mensagens pendentes cada sessão pode ter antes de o novo trabalho ser rejeitado.",
|
||||
"messageQueueEffective": "Efetivo:",
|
||||
"messageQueueEffectiveHelp": "O valor efetivo é usado nas novas admissões a partir de agora. As entradas já em fila não são removidas quando este valor é reduzido.",
|
||||
"messageQueueConfigurationLockTitle": "Gerido pela configuração",
|
||||
"messageQueueConfigurationLocked": "O valor de configuração YAML substitui a definição guardada. Altere o valor em config.yaml e reinicie o Kandev para o voltar a editar aqui.",
|
||||
"messageQueueEnvironmentLockTitle": "Gerido pelo ambiente",
|
||||
"messageQueueEnvironmentLocked": "{{variable}} substitui a definição guardada. Altere o valor no ambiente e reinicie o Kandev para o voltar a editar aqui.",
|
||||
"messageQueueLimitDescription": "Este limite aplica-se de imediato às mensagens recém-admitidas. Reduzi-lo nunca remove mensagens já em espera e as tentativas de entrega aceites mantêm-se seguras.",
|
||||
@@ -180,6 +182,7 @@
|
||||
"messageQueueRetry": "Tentar novamente",
|
||||
"messageQueueSaveFailed": "Não foi possível guardar as definições da fila de mensagens.",
|
||||
"messageQueueSourceDefault": "Pré-definição",
|
||||
"messageQueueSourceConfiguration": "Configuração",
|
||||
"messageQueueSourceEnvironment": "Ambiente",
|
||||
"messageQueueSourceSetting": "Definição guardada",
|
||||
"messageQueueTitle": "Fila de mensagens",
|
||||
|
||||
@@ -168,6 +168,8 @@
|
||||
"messageQueueDescription": "控制每个会话在新工作被拒绝前可以容纳多少条待处理消息。",
|
||||
"messageQueueEffective": "当前生效:",
|
||||
"messageQueueEffectiveHelp": "当前生效值会立即用于新的接纳操作。降低此值不会清理已有的排队条目。",
|
||||
"messageQueueConfigurationLockTitle": "由配置管理",
|
||||
"messageQueueConfigurationLocked": "YAML 配置值会覆盖已保存的设置。请更改 config.yaml 中的值并重启 Kandev,之后才能在此处再次编辑。",
|
||||
"messageQueueEnvironmentLockTitle": "由环境管理",
|
||||
"messageQueueEnvironmentLocked": "{{variable}} 会覆盖已保存的设置。请更改环境值并重启 Kandev,之后才能在此处再次编辑。",
|
||||
"messageQueueLimitDescription": "此限制会立即应用于新接纳的消息。降低限制不会移除已在等待的消息,已接受的投递重试仍然安全。",
|
||||
@@ -181,6 +183,7 @@
|
||||
"messageQueueRetry": "重试",
|
||||
"messageQueueSaveFailed": "保存消息队列设置失败。",
|
||||
"messageQueueSourceDefault": "默认值",
|
||||
"messageQueueSourceConfiguration": "配置",
|
||||
"messageQueueSourceEnvironment": "环境",
|
||||
"messageQueueSourceSetting": "已保存的设置",
|
||||
"messageQueueTitle": "消息队列",
|
||||
|
||||
@@ -168,6 +168,8 @@
|
||||
"messageQueueDescription": "控制每個工作階段在新工作被拒絕前可以容納多少條待處理訊息。",
|
||||
"messageQueueEffective": "目前生效:",
|
||||
"messageQueueEffectiveHelp": "目前生效值會立即用於新的接納操作。降低此值不會清理已有的排隊條目。",
|
||||
"messageQueueConfigurationLockTitle": "由設定管理",
|
||||
"messageQueueConfigurationLocked": "YAML 設定值會覆蓋已儲存的設定。請更改 config.yaml 中的值並重啓 Kandev,之後才能在此處再次編輯。",
|
||||
"messageQueueEnvironmentLockTitle": "由環境管理",
|
||||
"messageQueueEnvironmentLocked": "{{variable}} 會覆蓋已儲存的設定。請更改環境值並重啓 Kandev,之後才能在此處再次編輯。",
|
||||
"messageQueueLimitDescription": "此限制會立即應用於新接納的訊息。降低限制不會移除已在等待的訊息,已接受的投遞重試仍然安全。",
|
||||
@@ -181,6 +183,7 @@
|
||||
"messageQueueRetry": "重試",
|
||||
"messageQueueSaveFailed": "儲存訊息佇列設定失敗。",
|
||||
"messageQueueSourceDefault": "預設值",
|
||||
"messageQueueSourceConfiguration": "設定",
|
||||
"messageQueueSourceEnvironment": "環境",
|
||||
"messageQueueSourceSetting": "已儲存的設定",
|
||||
"messageQueueTitle": "訊息佇列",
|
||||
|
||||
@@ -168,6 +168,8 @@
|
||||
"messageQueueDescription": "控制每個工作階段在新工作被拒絕前可以容納多少條待處理訊息。",
|
||||
"messageQueueEffective": "目前生效:",
|
||||
"messageQueueEffectiveHelp": "目前生效值會立即用於新的接納操作。降低此值不會清理已有的排隊條目。",
|
||||
"messageQueueConfigurationLockTitle": "由設定管理",
|
||||
"messageQueueConfigurationLocked": "YAML 設定值會覆蓋已儲存的設定。請更改 config.yaml 中的值並重啟 Kandev,之後才能在此處再次編輯。",
|
||||
"messageQueueEnvironmentLockTitle": "由環境管理",
|
||||
"messageQueueEnvironmentLocked": "{{variable}} 會覆蓋已儲存的設定。請更改環境值並重啟 Kandev,之後才能在此處再次編輯。",
|
||||
"messageQueueLimitDescription": "此限制會立即應用於新接納的訊息。降低限制不會移除已在等待的訊息,已接受的投遞重試仍然安全。",
|
||||
@@ -181,6 +183,7 @@
|
||||
"messageQueueRetry": "重試",
|
||||
"messageQueueSaveFailed": "儲存訊息佇列設定失敗。",
|
||||
"messageQueueSourceDefault": "預設值",
|
||||
"messageQueueSourceConfiguration": "設定",
|
||||
"messageQueueSourceEnvironment": "環境",
|
||||
"messageQueueSourceSetting": "已儲存的設定",
|
||||
"messageQueueTitle": "訊息佇列",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Startup Configuration Uses One Typed Source Model
|
||||
|
||||
- **Status:** accepted
|
||||
- **Date:** 2026-08-20
|
||||
- **Area:** backend, agentctl, frontend, cli, security, operations
|
||||
- **Spec:** [Startup configuration parity](../specs/platform/startup-configuration-parity.md)
|
||||
|
||||
## Context
|
||||
|
||||
Kandev reads startup configuration in several places. The common configuration
|
||||
package uses typed YAML and environment bindings. The Go launcher reads a
|
||||
smaller environment-only set. Backend and agentctl packages also read some
|
||||
environment variables directly.
|
||||
|
||||
This creates different behavior for one logical setting. It also means that a
|
||||
YAML value can arrive too late for the launcher. Operators cannot use one
|
||||
durable file for stable startup configuration.
|
||||
|
||||
Kandev already uses a first-readable-file model. It checks the working
|
||||
directory and then `/etc/kandev`. Kandev also has a stable home directory, but
|
||||
it does not search that location for configuration.
|
||||
|
||||
## Decision
|
||||
|
||||
Kandev will maintain one typed catalog for stable, operator-facing startup
|
||||
settings. Each catalog entry defines its YAML key, compatible environment
|
||||
variables, default, sensitivity, and owning component.
|
||||
|
||||
The launcher and backend will use the same catalog and source resolution rules.
|
||||
The launcher will read only the bootstrap projection that it needs. The backend
|
||||
will read the complete typed configuration. Consumers will receive resolved
|
||||
values through constructors or explicit startup configuration. Managed
|
||||
agentctl processes will receive their resolved subset through explicit child
|
||||
configuration.
|
||||
|
||||
Kandev will not copy YAML values into public process environment variables.
|
||||
An internal launcher handoff can identify the already selected configuration
|
||||
file so the child process reads the same file after a working-directory change.
|
||||
That handoff is process wiring and is not a public operator setting.
|
||||
|
||||
The precedence for startup settings will be launch flag, explicit environment,
|
||||
selected YAML, then built-in or profile default. A database override, when one
|
||||
exists, follows YAML and precedes the final default.
|
||||
|
||||
Kandev will select one file in this order:
|
||||
|
||||
1. Working-directory `config.yaml`.
|
||||
2. `<KANDEV_HOME_DIR>/config.yaml`, with `~/.kandev` as the default home.
|
||||
3. `/etc/kandev/config.yaml`.
|
||||
|
||||
The first existing candidate owns startup. An unreadable or invalid candidate
|
||||
causes startup to fail. Kandev will not merge files or fall through after an
|
||||
error.
|
||||
|
||||
The explicit home flag or environment variable selects the home path before
|
||||
file discovery. A selected home file cannot relocate itself through `homeDir`.
|
||||
Working-directory and system files can set `homeDir` because their own path does
|
||||
not depend on that value.
|
||||
|
||||
Stable startup secrets will support YAML. Kandev will mark secret fields in the
|
||||
catalog, avoid value logging, and warn about broad Unix read permissions on a
|
||||
secret-bearing file. The warning will recommend owner-only mode `0600`.
|
||||
|
||||
The public configuration reference will inventory every stable startup
|
||||
environment variable. It will name the YAML equivalent or explain why the
|
||||
variable is internal, generated, test-only, debug-only, deprecated, or managed
|
||||
through another surface.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Operators can keep stable startup configuration in
|
||||
`~/.kandev/config.yaml` without repeating environment setup.
|
||||
- Environment variables remain compatible and keep override priority.
|
||||
- Launcher and backend behavior become consistent for ports, paths, logging,
|
||||
proxy trust, and other startup settings.
|
||||
- Package-level environment reads for stable operator settings must move behind
|
||||
typed configuration boundaries.
|
||||
- Child process launch paths need explicit tests for agentctl configuration.
|
||||
- The message queue settings API needs a `configuration` source so the UI can
|
||||
describe YAML locks accurately.
|
||||
- Adding a stable startup environment variable now requires a catalog entry or
|
||||
an explicit exclusion.
|
||||
- File-based secrets are possible, but operators remain responsible for file
|
||||
access controls.
|
||||
- Configuration changes still require a restart.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### Copy YAML values into environment variables
|
||||
|
||||
This would reuse current direct environment reads. It would hide setting
|
||||
ownership, make provenance unreliable, and put file-based secrets into more
|
||||
process environments. We rejected it.
|
||||
|
||||
### Add only `server.trustedProxies`
|
||||
|
||||
This would fix the immediate proxy warning but preserve the split configuration
|
||||
contract. Operators would encounter the same issue for other settings. We
|
||||
rejected it.
|
||||
|
||||
### Expose every observed environment variable as YAML
|
||||
|
||||
Many variables are generated handoffs, test controls, debug tools, or platform
|
||||
detection inputs. Making them public would freeze internal implementation
|
||||
details. We rejected it in favor of a reviewed stable catalog and explicit
|
||||
exclusions.
|
||||
|
||||
### Add a public `--config` flag instead of home discovery
|
||||
|
||||
A flag is useful for one launch command but does not give manual and service
|
||||
launches a shared default. The home file provides that stable location. We can
|
||||
add an explicit path later if a separate use case requires it.
|
||||
|
||||
### Merge working-directory, home, and system files
|
||||
|
||||
Merging increases provenance and secret-handling complexity. It would also
|
||||
change the existing first-file behavior. We rejected it.
|
||||
@@ -182,4 +182,5 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
||||
| 2026-08-18-plugin-task-row-metadata | [Keep Task Row Plugin Metadata Generic](2026-08-18-plugin-task-row-metadata.md) | accepted | frontend | 2026-08-18 |
|
||||
| 2026-08-20-settings-prompt-editor-provider-ownership | [Settings Prompt Editors Own Monaco Completion Providers](2026-08-20-settings-prompt-editor-provider-ownership.md) | accepted | frontend | 2026-08-20 |
|
||||
| 2026-08-14-kandev-native-agent-rich-output | [Keep Agent Rich Output Host Native](2026-08-14-kandev-native-agent-rich-output.md) | accepted | backend, frontend, protocol, security | 2026-08-14 |
|
||||
| 2026-08-20-startup-configuration-source-parity | [Startup Configuration Uses One Typed Source Model](2026-08-20-startup-configuration-source-parity.md) | accepted | backend, agentctl, frontend, cli, security, operations | 2026-08-20 |
|
||||
| 2026-08-20-acp-client-non-underscore-extension-methods | [Route Non-Underscore Inbound Client Methods to the Extension Handler](2026-08-20-acp-client-non-underscore-extension-methods.md) | accepted | backend, protocol | 2026-08-20 |
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
spec: docs/specs/platform/startup-configuration-parity.md
|
||||
decision: docs/decisions/2026-08-20-startup-configuration-source-parity.md
|
||||
created: 2026-08-20
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Implementation Plan: Startup Configuration Parity
|
||||
|
||||
## Overview
|
||||
|
||||
Create one typed startup configuration contract for stable operator settings.
|
||||
Add automatic home configuration discovery. Make the Go launcher, backend, and
|
||||
managed agentctl processes use the same resolved values. Keep environment
|
||||
variables as compatible overrides.
|
||||
|
||||
The work starts with the common catalog and source resolver because every other
|
||||
task depends on those contracts. Launcher work follows so it can preserve the
|
||||
selected file and its bootstrap values. Backend consumers then move from direct
|
||||
environment reads to typed configuration. Agentctl settings use explicit child
|
||||
configuration across all executor paths. The final product slice reports YAML
|
||||
locks accurately in Message Queue settings and publishes the complete reference.
|
||||
|
||||
Implementation is sequential by default. Tasks 03 and 04 use different consumer
|
||||
packages after Task 01 defines their fields, but both depend on shared startup
|
||||
wiring from Task 02. No delegated execution is authorized by this plan.
|
||||
|
||||
## Configuration contract
|
||||
|
||||
The common configuration package owns a catalog entry for every stable startup
|
||||
setting. An entry records its YAML key, environment aliases, sensitivity,
|
||||
default policy, and owner. A separate exclusion list records internal,
|
||||
generated, test, debug-only, deprecated, and externally managed variables.
|
||||
Completeness tests compare the catalog with the audited public startup inventory.
|
||||
|
||||
File selection uses the working directory, resolved home, then `/etc/kandev`.
|
||||
The first existing candidate wins. Errors do not fall through. A private child
|
||||
handoff preserves the selected path when the launcher changes the backend
|
||||
working directory.
|
||||
|
||||
The implementation keeps YAML values in typed configuration. It does not create
|
||||
public environment variables from those values. Consumers accept typed values
|
||||
or explicit startup options.
|
||||
|
||||
## Implementation waves
|
||||
|
||||
### Wave 1: Shared contract
|
||||
|
||||
- [x] [Task 01: Configuration catalog and source resolution](task-01-configuration-catalog-and-source-resolution.md)
|
||||
|
||||
### Wave 2: Launcher bootstrap
|
||||
|
||||
- [x] [Task 02: Launcher configuration consumption](task-02-launcher-configuration-consumption.md)
|
||||
|
||||
### Wave 3: Backend consumers
|
||||
|
||||
- [x] [Task 03: Backend security and lifecycle settings](task-03-backend-security-and-lifecycle-settings.md)
|
||||
- [x] [Task 04: Backend capacity and service settings](task-04-backend-capacity-and-service-settings.md)
|
||||
|
||||
### Wave 4: Managed processes and product source reporting
|
||||
|
||||
- [x] [Task 05: Agentctl settings propagation](task-05-agentctl-settings-propagation.md)
|
||||
- [x] [Task 06: Message queue configuration lock](task-06-message-queue-configuration-lock.md)
|
||||
|
||||
### Wave 5: Public contract
|
||||
|
||||
- [x] [Task 07: Public configuration reference](task-07-public-configuration-reference.md)
|
||||
|
||||
## Cross-task acceptance
|
||||
|
||||
- Every stable operator-facing startup environment variable has a canonical
|
||||
YAML key or a reviewed exclusion.
|
||||
- Launch flags override environment values. Environment values override YAML.
|
||||
YAML overrides database, profile, or built-in defaults as applicable.
|
||||
- The launcher and backend use one selected file and agree on bootstrap values.
|
||||
- Working-directory configuration wins over home configuration. Home
|
||||
configuration wins over system configuration.
|
||||
- An invalid or unreadable first candidate fails startup.
|
||||
- A selected home file cannot relocate itself through `homeDir`.
|
||||
- Secret values never appear in logs. Broad Unix file permissions produce a
|
||||
warning for secret-bearing files.
|
||||
- Managed agentctl processes receive YAML-backed settings through Local,
|
||||
Worktree, Docker, Sprite, and SSH launch paths.
|
||||
- Message Queue settings report `configuration` for a YAML lock on desktop and
|
||||
mobile.
|
||||
- Existing environment-only deployments keep their behavior.
|
||||
|
||||
## Verification strategy
|
||||
|
||||
Each task follows TDD and records its RED and GREEN commands. The final combined
|
||||
gate runs backend configuration, launcher, runtime flag, queue, and agentctl
|
||||
tests. It also runs the focused frontend unit and responsive E2E tests, the
|
||||
internationalization checks, the public documentation validator, and a backend
|
||||
build.
|
||||
|
||||
The implementation turn must inspect current package paths before running each
|
||||
command. If a package moved after this plan, update the task file with the exact
|
||||
replacement instead of weakening the check.
|
||||
|
||||
## Risks
|
||||
|
||||
- Home discovery depends on the home path. Bootstrap resolution must not load a
|
||||
home file before it knows that path.
|
||||
- A launcher working-directory change can make the backend select a different
|
||||
file. The private selected-path handoff must pin the same file.
|
||||
- Current package-level environment reads run during initialization. They must
|
||||
move behind explicit startup configuration without introducing mutable global
|
||||
state.
|
||||
- Agentctl runs through several executors. A missing propagation path can make
|
||||
behavior depend on executor choice.
|
||||
- The runtime flag registry already owns feature and debug metadata. The new
|
||||
catalog must integrate with it instead of adding a second feature flag map.
|
||||
- Environment parsing has historical fallback rules. YAML validation must fail
|
||||
clearly without silently changing those legacy environment rules.
|
||||
- File mode checks differ by platform. Unix warnings need focused tests and a
|
||||
safe no-op on platforms without compatible permission bits.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `.env` loading, public `--config`, file merging, and live reload.
|
||||
- A startup configuration editor in Settings.
|
||||
- New keys for internal child wiring, generated tokens, packaging, mocks, E2E,
|
||||
debug-only controls, or UI-managed credentials.
|
||||
|
||||
## Verification results
|
||||
|
||||
- The common configuration, launcher, backend consumer, queue, agentctl, and
|
||||
lifecycle package suite passed 5,691 tests across 16 packages.
|
||||
- The focused queue API and frontend unit suites passed 65 backend tests and 27
|
||||
frontend tests.
|
||||
- The five-locale i18n check and public documentation validator passed.
|
||||
- The final browser E2E, backend build, full repository verification, commit,
|
||||
and PR checks are recorded in the delivery session and task results.
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
---
|
||||
id: "01-configuration-catalog-and-source-resolution"
|
||||
title: "Configuration catalog and source resolution"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/platform/startup-configuration-parity.md"
|
||||
---
|
||||
|
||||
# Task 01: Configuration catalog and source resolution
|
||||
|
||||
Build the shared typed contract before changing consumers. Add stable YAML
|
||||
fields, source metadata, file discovery, validation, and secret permission
|
||||
diagnostics to the common configuration package.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A canonical catalog records YAML key, compatible environment variables,
|
||||
sensitivity, owner, and default policy for every stable startup setting.
|
||||
- A reviewed exclusion list covers internal, generated, test, debug-only,
|
||||
deprecated, platform-detection, and externally managed variables.
|
||||
- Completeness tests fail when a stable environment setting has neither a
|
||||
catalog entry nor an exclusion.
|
||||
- Typed configuration includes every new key listed in the spec.
|
||||
- Source resolution checks working directory, resolved home, then
|
||||
`/etc/kandev`. It loads one file and never merges candidates.
|
||||
- The first existing unreadable or invalid file causes a clear startup error.
|
||||
- A selected home configuration file rejects `homeDir`. Working-directory and
|
||||
system files can set it.
|
||||
- Selected-file metadata records the path and whether a YAML key supplied each
|
||||
resolved value.
|
||||
- Secret-bearing files with broad Unix read permissions produce a warning that
|
||||
contains the path but no value. Secure files and files without secrets do not
|
||||
warn.
|
||||
- Existing environment bindings and profile defaults keep their precedence and
|
||||
behavior.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/common/config/config.go`
|
||||
- `apps/backend/internal/common/config/config_test.go`
|
||||
- `apps/backend/internal/common/config/catalog.go` (new)
|
||||
- `apps/backend/internal/common/config/catalog_test.go` (new)
|
||||
- `apps/backend/internal/common/config/source.go` (new)
|
||||
- `apps/backend/internal/common/config/source_test.go` (new)
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Add table tests for catalog completeness, aliases, sensitivity, source
|
||||
precedence, home bootstrap, invalid first candidates, relocation rejection,
|
||||
source provenance, and Unix permission warnings. Run them RED.
|
||||
2. Add typed sections and catalog metadata. Implement file selection without
|
||||
changing the current environment and profile precedence.
|
||||
3. Add secret-aware permission diagnostics that never format secret values.
|
||||
4. Run the focused tests GREEN. Run the complete common configuration and
|
||||
runtime flag suites to prove registry compatibility.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/common/config -run '^Test(ConfigurationCatalog|ConfigSource|ConfigPrecedence|HomeConfig|SecretConfigPermissions)' -count=1
|
||||
cd apps/backend && go test ./internal/common/config ./internal/runtimeflags -count=1
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
- A broad environment scan can accidentally define internal variables as public
|
||||
contract. The exclusion reason must be explicit and testable.
|
||||
- Logging a decoded configuration object can expose secrets. Diagnostics must
|
||||
use catalog metadata and paths only.
|
||||
- Viper aliases and mapstructure names can drift. Catalog completeness tests
|
||||
must compare the actual typed bindings.
|
||||
|
||||
## Output contract
|
||||
|
||||
Record RED and GREEN results, the final catalog shape, every exclusion class,
|
||||
permission warning evidence, files changed, and remaining risks in `## Results`.
|
||||
|
||||
## Results
|
||||
|
||||
RED:
|
||||
|
||||
- `go test ./internal/common/config -run 'TestLoadReadsStableYAMLFields|TestLoadDiscoversHomeConfiguration|TestHomeConfigurationCannotRelocateItself|TestConfigSourceMetadataReportsSelectedFileAndYAMLValues' -count=1`
|
||||
failed on the missing typed fields, home discovery, relocation guard, and
|
||||
source metadata as expected.
|
||||
- `TestInvalidYAMLRangeNamesSettingAndFile` also failed before range validation
|
||||
was added.
|
||||
|
||||
GREEN:
|
||||
|
||||
- `go test ./internal/common/config -run '^Test(ConfigurationCatalog|ConfigSource|ConfigPrecedence|HomeConfig|SecretConfigPermissions)' -count=1`
|
||||
passed with 11 tests.
|
||||
- `go test ./internal/common/config ./internal/runtimeflags -count=1`
|
||||
passed with 108 tests.
|
||||
|
||||
The shared contract now exposes typed task, credential, capacity, queue,
|
||||
agentctl, planning, observability, and launcher sections. `ConfigurationCatalog`
|
||||
records canonical keys, environment aliases, owners, defaults, and sensitivity.
|
||||
`ConfigurationExclusions` records internal wiring, generated values, packaging,
|
||||
workspace injection, profile, test, and debug-only variables. `ConfigSource`
|
||||
records the selected file, per-key source, and path-only permission warnings.
|
||||
|
||||
Discovery uses working directory, resolved home, then `/etc/kandev`; it selects
|
||||
one existing file and does not merge or fall through after read or validation
|
||||
errors. The selected home file rejects `homeDir`. YAML values are decoded
|
||||
without copying them into the process environment, while explicit environment
|
||||
aliases retain override priority and legacy invalid-value fallback behavior.
|
||||
Secret-bearing files with broad Unix read permissions emit a path-only warning
|
||||
recommending mode `0600`.
|
||||
|
||||
Files changed:
|
||||
|
||||
- `apps/backend/internal/common/config/config.go`
|
||||
- `apps/backend/internal/common/config/catalog.go`
|
||||
- `apps/backend/internal/common/config/catalog_test.go`
|
||||
- `apps/backend/internal/common/config/source.go`
|
||||
- `apps/backend/internal/common/config/source_test.go`
|
||||
- `apps/backend/internal/common/config/validation.go`
|
||||
- `apps/backend/internal/profiles/profiles.go`
|
||||
|
||||
Review remediation:
|
||||
|
||||
- The completeness test now compares catalog entries and exclusions with an
|
||||
independent audited startup-environment inventory.
|
||||
- A synthetic uncataloged variable regression proves that the audit rejects
|
||||
omissions instead of relying on a minimum count or selected keys.
|
||||
- GREEN: `go test ./internal/common/config -count=1` passed with 81 tests;
|
||||
`go test ./internal/common/config ./internal/runtimeflags -count=1` passed
|
||||
with 115 tests.
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
---
|
||||
id: "02-launcher-configuration-consumption"
|
||||
title: "Launcher configuration consumption"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-configuration-catalog-and-source-resolution"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/platform/startup-configuration-parity.md"
|
||||
---
|
||||
|
||||
# Task 02: Launcher configuration consumption
|
||||
|
||||
Make every Go launcher mode resolve its bootstrap settings from the shared
|
||||
configuration contract before it starts the backend.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `dev`, `start`, `run`, and `service` use the same source resolver.
|
||||
- Launch flags override environment values. Environment values override YAML.
|
||||
- The launcher honors YAML for `homeDir`, `server.host`, `server.port`,
|
||||
`database.path`, `logging.level`, `launcher.webPort`,
|
||||
`launcher.healthTimeoutMs`, and `launcher.noBrowser`.
|
||||
- Compatibility environment variables keep their current priority, including
|
||||
`KANDEV_BACKEND_PORT` over `KANDEV_PORT`.
|
||||
- The launcher does not replace a YAML `database.path` with its default path.
|
||||
- The child backend reads the exact file selected by the launcher even when its
|
||||
working directory differs.
|
||||
- Internal path handoff remains private and does not appear in the public
|
||||
operator catalog.
|
||||
- Launches without a configuration file keep current defaults.
|
||||
- Invalid bootstrap configuration stops before child startup and reports the
|
||||
selected file.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/launcher/constants.go`
|
||||
- `apps/backend/internal/launcher/ports.go`
|
||||
- `apps/backend/internal/launcher/health.go`
|
||||
- `apps/backend/internal/launcher/env.go`
|
||||
- `apps/backend/internal/launcher/start.go`
|
||||
- `apps/backend/internal/launcher/dev.go`
|
||||
- `apps/backend/internal/launcher/service.go`
|
||||
- `apps/backend/internal/launcher/*_test.go`
|
||||
- `apps/backend/internal/common/config/source.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01 defines the catalog, source resolver, and typed launcher section.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Add launcher tests for each precedence layer, YAML database path, home file
|
||||
discovery, selected-path handoff, no-browser, health timeout, and port
|
||||
compatibility. Run them RED.
|
||||
2. Add a bootstrap configuration projection and replace direct stable
|
||||
environment reads in the launcher.
|
||||
3. Preserve only required internal child wiring in the child environment.
|
||||
4. Run focused tests GREEN. Run the complete launcher suite and build the Go
|
||||
launcher binary.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/launcher -run '^Test.*(Config|Port|Health|Browser|DatabasePath|Home)' -count=1
|
||||
cd apps/backend && go test ./internal/launcher -count=1
|
||||
cd apps/backend && go build ./cmd/kandev
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
- Service installation and direct launch can have different working
|
||||
directories. Tests must cover the selected-path handoff.
|
||||
- The internal backend port handoff is not operator configuration. Removing it
|
||||
would break child startup even after public port resolution is typed.
|
||||
- `homeDir` controls data paths and configuration discovery. Bootstrap code
|
||||
must resolve it only once.
|
||||
|
||||
## Output contract
|
||||
|
||||
Record RED and GREEN results, precedence evidence for every launcher field,
|
||||
child handoff behavior, files changed, and remaining risks in `## Results`.
|
||||
|
||||
## Results
|
||||
|
||||
- RED: `go test ./internal/launcher -run "^Test(Launcher|DevBootstrap)" -count=1`
|
||||
failed to compile before the bootstrap projection and private handoff existed.
|
||||
- GREEN: `go test ./internal/launcher -run '^Test.*(Config|Port|Health|Browser|DatabasePath|Home)' -count=1`
|
||||
passed, covering YAML values, environment-over-YAML, flag-over-environment,
|
||||
dev web-port selection, health timeout, browser suppression, database path,
|
||||
invalid-file reporting, and selected-file handoff.
|
||||
- GREEN: `go test ./internal/launcher -count=1` passed.
|
||||
- GREEN: `go build ./cmd/kandev` passed.
|
||||
- The launcher now loads the shared config before port, data-directory, and
|
||||
child startup decisions. Start, run, dev, and service installation preserve
|
||||
the selected file through private `KANDEV_INTERNAL_CONFIG_FILE` wiring.
|
||||
- YAML database and logging values remain typed in the child process; the
|
||||
launcher does not copy those YAML values into public environment overrides.
|
||||
- Service units preserve YAML home/logging values and emit the private file
|
||||
handoff. Existing no-file defaults and `KANDEV_BACKEND_PORT` over
|
||||
`KANDEV_PORT` behavior remain covered by the complete launcher suite.
|
||||
|
||||
Review remediation:
|
||||
|
||||
- Service bootstrap discovery now resolves flag, explicit environment, and
|
||||
service-mode default homes before selecting `config.yaml`. Systemd and
|
||||
launchd tests cover flag-over-YAML and environment-over-YAML service metadata.
|
||||
- The generated service unit emits `KANDEV_HOME_DIR` unless YAML is the actual
|
||||
winning source, so a stronger flag or environment value cannot be shadowed
|
||||
by the pinned file.
|
||||
- Dev backend and Vite readiness waits now use the same typed YAML health
|
||||
timeout. The regression captures both timeout arguments.
|
||||
- GREEN: `go test ./internal/launcher -count=1` passed with 230 tests;
|
||||
`go build ./cmd/kandev` passed; changed-file `golangci-lint` passed.
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
---
|
||||
id: "03-backend-security-and-lifecycle-settings"
|
||||
title: "Backend security and lifecycle settings"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on: ["02-launcher-configuration-consumption"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/platform/startup-configuration-parity.md"
|
||||
---
|
||||
|
||||
# Task 03: Backend security and lifecycle settings
|
||||
|
||||
Move stable security and lifecycle consumers from direct environment access to
|
||||
the resolved typed configuration.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `server.trustedProxies` configures Gin proxy trust with the existing
|
||||
fail-closed parsing and warning behavior.
|
||||
- `tasks.preparationTimeout` controls setup and derived launch budgets without a
|
||||
package-initialization environment read.
|
||||
- `credentials.file` controls the operator credential file path through typed
|
||||
startup configuration.
|
||||
- Environment variables keep their existing behavior and override YAML.
|
||||
- YAML type or value errors fail startup. Legacy invalid environment values keep
|
||||
their documented fallback behavior.
|
||||
- Constructors or startup options receive resolved values. The affected
|
||||
packages do not gain mutable global configuration.
|
||||
- Logs identify invalid setting names and sources without exposing secret data.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/backendapp/trustedproxies.go`
|
||||
- `apps/backend/internal/backendapp/trustedproxies_test.go`
|
||||
- `apps/backend/internal/backendapp/agents.go`
|
||||
- `apps/backend/internal/backendapp/main.go`
|
||||
- `apps/backend/internal/common/constants/timeouts.go`
|
||||
- `apps/backend/internal/common/constants/timeouts_test.go`
|
||||
- Agent runtime constructors and tests that consume preparation timeouts
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 02 makes the launcher and backend agree on the selected configuration.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Add tests that set only YAML for trusted proxies, preparation timeout, and
|
||||
credentials file. Confirm current consumers ignore those values.
|
||||
2. Add environment-over-YAML cases and invalid YAML cases.
|
||||
3. Pass typed values into consumers and remove in-scope direct environment
|
||||
reads.
|
||||
4. Run focused tests GREEN. Run backend application, constants, auth, and
|
||||
runtime launch regressions.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/backendapp -run '^Test.*(TrustedProxies|CredentialsFile|StartupConfig)' -count=1
|
||||
cd apps/backend && go test ./internal/common/constants ./internal/agent/runtime/lifecycle -run '^Test.*(Preparation|LaunchTimeout|Config)' -count=1
|
||||
cd apps/backend && go test ./internal/backendapp/... ./internal/auth/... -count=1
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
- Trusted proxy configuration affects client IP, login rate limiting, and stored
|
||||
session IP data. The secure no-trust default must remain explicit.
|
||||
- Preparation timeouts currently exist as package-level values. Initialization
|
||||
order can preserve stale environment behavior unless the value becomes an
|
||||
explicit dependency.
|
||||
- The credentials file can contain secrets even though the path itself is not a
|
||||
secret. Error logs must not dump file contents.
|
||||
|
||||
## Output contract
|
||||
|
||||
Record RED and GREEN results, environment compatibility evidence, constructor
|
||||
changes, direct-read removal, files changed, and remaining risks in
|
||||
`## Results`.
|
||||
|
||||
## Results
|
||||
|
||||
RED:
|
||||
|
||||
- The new typed trusted-proxy, credential-file, and preparation-timeout tests
|
||||
initially failed until the backend consumers accepted resolved startup
|
||||
values. The old package-initialization timeout read also had to be removed
|
||||
before the typed startup value could take effect.
|
||||
|
||||
GREEN:
|
||||
|
||||
- `go test ./internal/backendapp -run '^Test(ConfigureTrustedProxiesUsesTyped|CredentialFilePath)' -count=1` passed.
|
||||
- `go test ./internal/common/constants -run '^TestApplyPreparation' -count=1` passed.
|
||||
- The affected package suite passed as part of `go test ./internal/common/config ./internal/common/constants ./internal/common/subproc ./internal/launcher ./internal/backendapp ./internal/system/queuesettings ./internal/system ./internal/gateway/websocket ./internal/task/service ./internal/runs/scheduler ./internal/office/service ./internal/agentctl/server/config ./internal/agentctl/server/adapter/transport/acp ./internal/agentctl/tracing ./internal/agent/runtime/agentctl/launcher ./internal/agent/runtime/lifecycle`, with 5,691 tests across 16 packages.
|
||||
|
||||
Trusted proxy parsing, credential-file selection, and preparation timeout
|
||||
application now consume typed configuration. The legacy environment aliases
|
||||
retain their fallback behavior, and invalid YAML values are rejected during
|
||||
configuration loading. Preparation timeout state is applied explicitly at
|
||||
backend startup instead of being captured by package initialization.
|
||||
|
||||
Files changed:
|
||||
|
||||
- `apps/backend/internal/backendapp/trustedproxies.go`
|
||||
- `apps/backend/internal/backendapp/trustedproxies_test.go`
|
||||
- `apps/backend/internal/backendapp/agents.go`
|
||||
- `apps/backend/internal/backendapp/agents_startup_config_test.go`
|
||||
- `apps/backend/internal/backendapp/main.go`
|
||||
- `apps/backend/internal/common/constants/timeouts.go`
|
||||
- `apps/backend/internal/common/constants/timeouts_test.go`
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
---
|
||||
id: "04-backend-capacity-and-service-settings"
|
||||
title: "Backend capacity and service settings"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on: ["03-backend-security-and-lifecycle-settings"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/platform/startup-configuration-parity.md"
|
||||
---
|
||||
|
||||
# Task 04: Backend capacity and service settings
|
||||
|
||||
Move stable capacity and service timing settings behind explicit typed startup
|
||||
configuration.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `limits.ghMaxConcurrent` and `limits.gitMaxConcurrent` configure subprocess
|
||||
admission without package-initialization environment reads.
|
||||
- `limits.lspMaxConnections` configures LSP gateway capacity.
|
||||
- `planning.coalesceWindowMs` configures plan update coalescing.
|
||||
- `office.schedulerTickMs` configures the scheduler through one shared value and
|
||||
removes duplicate parsing.
|
||||
- Environment variables override YAML. YAML overrides built-in defaults.
|
||||
- Invalid YAML values fail startup with the key and selected file. Invalid
|
||||
legacy environment values keep current fallback behavior.
|
||||
- Values are immutable for one backend process and require restart.
|
||||
- Focused concurrency tests prove configured limits without timing-sensitive
|
||||
sleeps.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/common/subproc/shared.go`
|
||||
- `apps/backend/internal/common/subproc/*_test.go`
|
||||
- `apps/backend/internal/gateway/websocket/lsp_capacity.go`
|
||||
- `apps/backend/internal/gateway/websocket/*_test.go`
|
||||
- `apps/backend/internal/task/service/plan_service.go`
|
||||
- `apps/backend/internal/task/service/*_test.go`
|
||||
- `apps/backend/internal/runs/scheduler/scheduler.go`
|
||||
- `apps/backend/internal/runs/scheduler/*_test.go`
|
||||
- Office scheduler construction and tests
|
||||
- Backend startup constructors that assemble these services
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 03 establishes the backend pattern for replacing direct stable environment
|
||||
reads with explicit configuration.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Add focused YAML-only and environment-over-YAML tests for every setting. Run
|
||||
them RED.
|
||||
2. Add constructor options or immutable startup setters at each owning package.
|
||||
3. Remove duplicate environment parsing and package-initialization reads.
|
||||
4. Run focused tests GREEN. Run package regressions and race-sensitive tests
|
||||
where those packages already support them.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/common/subproc ./internal/gateway/websocket -run '^Test.*(Config|Capacity|Concurrent)' -count=1
|
||||
cd apps/backend && go test ./internal/task/service ./internal/runs/scheduler -run '^Test.*(Config|Coalesce|SchedulerTick)' -count=1
|
||||
cd apps/backend && go test ./internal/common/subproc ./internal/gateway/websocket ./internal/task/service ./internal/runs/scheduler -count=1
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
- Shared subprocess gates are process-wide singletons. Tests must prove that
|
||||
startup constructs them once with the resolved limit.
|
||||
- Two scheduler packages currently parse the same variable. Leaving either read
|
||||
in place would preserve executor-dependent behavior.
|
||||
- Millisecond settings can create busy loops. Typed validation needs a positive
|
||||
lower bound that matches current safe behavior.
|
||||
|
||||
## Output contract
|
||||
|
||||
Record RED and GREEN results, the immutable ownership boundary for each value,
|
||||
removed duplicate parsing, files changed, and remaining risks in `## Results`.
|
||||
|
||||
## Results
|
||||
|
||||
RED:
|
||||
|
||||
- Capacity and scheduler tests initially failed until typed startup options
|
||||
replaced package-initialization environment reads and the two scheduler
|
||||
packages shared one resolved tick interval.
|
||||
|
||||
GREEN:
|
||||
|
||||
- `go test ./internal/common/config ./internal/common/constants ./internal/common/subproc ./internal/gateway/websocket ./internal/task/service ./internal/runs/scheduler ./internal/office/service ./internal/backendapp -run '^Test.*(Config|Capacity|Concurrent|TrustedProxies|CredentialsFile|Preparation|LaunchTimeout|Coalesce|SchedulerTick|Startup)' -count=1` passed.
|
||||
- The complete affected package suite passed as part of the 5,691-test,
|
||||
16-package backend run recorded in Task 03.
|
||||
|
||||
The backend now applies typed Git and GitHub admission caps, LSP capacity,
|
||||
planning coalescing, and Office scheduler timing at startup. The shared
|
||||
subprocess singleton is configured once, scheduler parsing is centralized,
|
||||
and invalid legacy environment values retain their previous default fallback.
|
||||
The configured values are immutable for the backend process and require a
|
||||
restart.
|
||||
|
||||
Files changed:
|
||||
|
||||
- `apps/backend/internal/common/subproc/admission.go`
|
||||
- `apps/backend/internal/common/subproc/shared.go`
|
||||
- `apps/backend/internal/common/subproc/shared_test.go`
|
||||
- `apps/backend/internal/common/subproc/throttle.go`
|
||||
- `apps/backend/internal/gateway/websocket/lsp_handler.go`
|
||||
- `apps/backend/internal/gateway/websocket/setup.go`
|
||||
- `apps/backend/internal/gateway/websocket/lsp_capacity_test.go`
|
||||
- `apps/backend/internal/task/service/plan_service.go`
|
||||
- `apps/backend/internal/task/service/plan_service_test.go`
|
||||
- `apps/backend/internal/runs/scheduler/scheduler.go`
|
||||
- `apps/backend/internal/office/service/scheduler_integration.go`
|
||||
- `apps/backend/internal/backendapp/main.go`
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
id: "05-agentctl-settings-propagation"
|
||||
title: "Agentctl settings propagation"
|
||||
status: done
|
||||
wave: 4
|
||||
depends_on: ["04-backend-capacity-and-service-settings"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/platform/startup-configuration-parity.md"
|
||||
---
|
||||
|
||||
# Task 05: Agentctl settings propagation
|
||||
|
||||
Resolve agentctl settings in the backend and pass them explicitly through every
|
||||
managed agentctl launch path.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `agentctl.idleTimeout` configures idle session cleanup.
|
||||
- `agentctl.idleReaperInterval` configures the reaper interval.
|
||||
- `agentctl.notificationQueueCapacity` configures ACP notification buffering.
|
||||
- `observability.otlpEndpoint` configures tracing for the backend and managed
|
||||
agentctl processes.
|
||||
- Environment values override YAML and keep compatibility.
|
||||
- Local, Worktree, Docker, Sprite, and SSH launches receive the same resolved
|
||||
values.
|
||||
- Remote and container configuration uses an explicit child contract. It does
|
||||
not depend on inheriting the launcher's host environment.
|
||||
- Missing or invalid required child configuration fails that launch clearly.
|
||||
- Debug-only agentctl logging and retention variables remain excluded.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/agentctl/server/config/config.go`
|
||||
- `apps/backend/internal/agentctl/server/config/config_test.go`
|
||||
- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go`
|
||||
- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_queue_test.go`
|
||||
- `apps/backend/internal/agentctl/tracing/otel.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/manager_startup.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/executor_docker.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/executor_standalone_test.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/executor_sprites*.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/executor_ssh*.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 04 completes the backend typed-consumer pattern. Task 01 provides the
|
||||
agentctl and observability fields.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Add server and adapter tests for YAML-only resolved values. Run them RED.
|
||||
2. Add launch contract tests that inspect Local, Docker, Sprite, and SSH child
|
||||
configuration. Include Worktree through its local launch path.
|
||||
3. Replace direct stable environment reads in agentctl with explicit options.
|
||||
4. Thread the resolved child contract through every executor.
|
||||
5. Run focused tests GREEN. Run the complete agentctl and lifecycle suites.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/agentctl/server/config ./internal/agentctl/server/adapter/transport/acp ./internal/agentctl/tracing -run '^Test.*(Config|Idle|Queue|OTLP)' -count=1
|
||||
cd apps/backend && go test ./internal/agent/runtime/lifecycle -run '^Test.*(Agentctl|Standalone|Docker|Sprite|SSH).*Config' -count=1
|
||||
cd apps/backend && go test ./internal/agentctl/... ./internal/agent/runtime/lifecycle -count=1
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
- Container and remote script quoting can corrupt duration or endpoint values.
|
||||
Tests must assert the structured launch input before shell rendering and the
|
||||
rendered result where applicable.
|
||||
- Utility and passthrough agentctl launches can use separate code paths. The
|
||||
launch audit must include them.
|
||||
- Tracing already uses a standard OpenTelemetry environment name. YAML support
|
||||
must not change its environment override semantics.
|
||||
|
||||
## Output contract
|
||||
|
||||
Record RED and GREEN results, executor coverage, the child configuration shape,
|
||||
direct-read removal, files changed, and remaining risks in `## Results`.
|
||||
|
||||
## Results
|
||||
|
||||
RED:
|
||||
|
||||
- Agentctl server and executor contract tests initially failed until the
|
||||
managed child configuration was represented in `common/config` and threaded
|
||||
through lifecycle requests.
|
||||
|
||||
GREEN:
|
||||
|
||||
- `go test ./internal/agent/runtime/agentctl/launcher ./internal/agent/runtime/lifecycle ./internal/agentctl/server/config ./internal/agentctl/server/adapter/transport/acp ./internal/common/config -run '^Test.*(Startup|EnvironmentWithOverrides|Queue|LoadWithStartup|ConfigurationCatalog|Agentctl)' -count=1` passed 148 tests across 5 packages.
|
||||
- `go test ./internal/common/config ./internal/agentctl/server/config ./internal/agentctl/server/adapter/transport/acp ./internal/agentctl/tracing ./internal/agent/runtime/agentctl/launcher ./internal/agent/runtime/lifecycle ./internal/backendapp -run '^Test.*(Startup|Config|Queue|Agentctl|Explicit|Capacity|CredentialFilePath|TrustedProxies)' -count=1` passed 393 tests across 7 packages.
|
||||
- The complete affected package suite passed in the 5,691-test backend run recorded in Task 03.
|
||||
|
||||
The backend resolves one validated `AgentctlStartupConfig` and passes it
|
||||
through Local, Worktree, Docker, Sprite, and SSH launch paths. The private
|
||||
contract carries idle timeout, reaper interval, notification queue capacity,
|
||||
and OTLP endpoint values. Managed agentctl processes load that contract before
|
||||
legacy standalone compatibility paths; malformed contracts fail launch rather
|
||||
than silently falling back. Host environment inheritance is removed where it
|
||||
could conflict with the managed contract, while direct standalone adapters
|
||||
retain compatibility reads for existing callers and tests.
|
||||
|
||||
Files changed include the common contract, agentctl server/config/adapter and
|
||||
tracing packages, lifecycle manager and executor launch paths, and the focused
|
||||
launcher, adapter, configuration, and lifecycle tests.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user