package configrefresh_test import ( "context" "os" "path/filepath" "testing" "iop/apps/edge/internal/configrefresh" "iop/packages/go/config" ) func writeYAML(t *testing.T, dir, name, content string) string { t.Helper() path := filepath.Join(dir, name) if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatalf("write %s: %v", name, err) } return path } // buildNormalizedCurrent creates a current config that mimics what a running // serve process holds after loadRuntimeConfig normalization. Empty logging // paths are resolved, explicit logging paths are preserved, and bootstrap // artifact_dir is resolved to the effective runtime path. func buildNormalizedCurrent(t *testing.T, path string) *config.EdgeConfig { t.Helper() cfg, err := config.LoadEdge(path) if err != nil { t.Fatalf("load current: %v", err) } // Apply the same normalization that loadRuntimeConfig performs. // NOTE: explicit relative logging.path is preserved exactly to match // edgecmd.ResolveEdgeLogPath – only empty paths are resolved. if cfg.Logging.Path == "" { cfg.Logging.Path = _normalizedLogPath() } // Explicit absolute / relative paths are intentionally left untouched. cfg.Bootstrap.ArtifactDir = _normalizeArtifactDir(cfg.Bootstrap.ArtifactDir) return cfg } func _normalizedLogPath() string { if exe, err := os.Executable(); err == nil { return filepath.Join(filepath.Dir(exe), "logs", "edge.log") } if wd, err := os.Getwd(); err == nil { return filepath.Join(wd, "logs", "edge.log") } return "logs/edge.log" } func _normalizeArtifactDir(dir string) string { if dir == "" { dir = "artifacts" } if filepath.IsAbs(dir) { return dir } return filepath.Join(binaryDirForTesting(), dir) } func binaryDirForTesting() string { if exe, err := os.Executable(); err == nil { return filepath.Dir(exe) } if wd, err := os.Getwd(); err == nil { return wd } return "." } const baseEdgeYAML = ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 max_queue: 4 queue_timeout_ms: 5000 ` // TestClassifyProviderCapacityApplied verifies S07: provider capacity change is classified as applied. func TestClassifyProviderCapacityApplied(t *testing.T) { dir := t.TempDir() currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML) candidateYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 8 max_queue: 4 queue_timeout_ms: 5000 ` candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) ctx := context.Background() result, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-s07", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusApplied { t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary) } if len(result.Changes) == 0 { t.Fatalf("expected at least one change") } found := false for _, c := range result.Changes { if c.Class == configrefresh.StatusApplied { found = true } if c.Class == configrefresh.StatusRestartRequired { t.Errorf("unexpected restart_required change: %s", c.Path) } } if !found { t.Errorf("no applied change found in: %+v", result.Changes) } } // TestClassifyListenPortRestartRequired verifies S08: listen port change is classified as restart_required. func TestClassifyListenPortRestartRequired(t *testing.T) { dir := t.TempDir() currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML) candidateYAML := ` server: listen: "0.0.0.0:19090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 max_queue: 4 queue_timeout_ms: 5000 ` candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) ctx := context.Background() result, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-s08", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusRestartRequired { t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusRestartRequired, result.Status, result.Summary) } found := false for _, c := range result.Changes { if c.Path == "server.listen" && c.Class == configrefresh.StatusRestartRequired { found = true } } if !found { t.Errorf("server.listen restart_required change not found in: %+v", result.Changes) } } // TestRefreshInvalidYAMLRejected verifies S09: invalid YAML is classified as rejected. func TestRefreshInvalidYAMLRejected(t *testing.T) { dir := t.TempDir() currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML) invalidPath := writeYAML(t, dir, "invalid.yaml", "nodes: [\n - bad: [unterminated") current := buildNormalizedCurrent(t, currentPath) ctx := context.Background() result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: invalidPath, RequestID: "test-s09-yaml", }) if err != nil { t.Fatalf("Evaluate should not return error for rejected config: %v", err) } if result.Status != configrefresh.StatusRejected { t.Errorf("expected status=%q, got %q", configrefresh.StatusRejected, result.Status) } if candidate != nil { t.Errorf("expected nil candidate for rejected result") } } func TestClassifyLongContextThresholdApplied(t *testing.T) { dir := t.TempDir() currentYAML := ` server: listen: "0.0.0.0:9090" long_context_threshold_tokens: 100000 nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true ` candidateYAML := ` server: listen: "0.0.0.0:9090" long_context_threshold_tokens: 120000 nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-long-threshold", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusApplied { t.Fatalf("expected status=%q, got %q changes=%+v", configrefresh.StatusApplied, result.Status, result.Changes) } found := false for _, change := range result.Changes { if change.Path == "long_context_threshold_tokens" { found = true if change.Class != configrefresh.StatusApplied { t.Errorf("threshold class: got %q, want applied", change.Class) } if change.Previous != "100000" || change.Next != "120000" { t.Errorf("threshold diff: got %s→%s", change.Previous, change.Next) } } if change.Class == configrefresh.StatusRestartRequired { t.Errorf("unexpected restart_required threshold change: %s", change.Path) } } if !found { t.Fatalf("long_context_threshold_tokens change not found in %+v", result.Changes) } } func TestClassifyModelCatalogProviderMappingApplied(t *testing.T) { dir := t.TempDir() currentYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 - id: "prov-b" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 models: - id: "qwen3.6:35b" display_name: "Qwen" context_window_tokens: 131072 default_max_tokens: 8192 min_max_tokens: 8192 providers: prov-a: "llama3.1" ` candidateYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 - id: "prov-b" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 models: - id: "qwen3.6:35b" display_name: "Qwen" context_window_tokens: 262144 default_max_tokens: 32768 min_max_tokens: 32768 default_thinking_token_budget: 8192 providers: prov-b: "llama3.1" ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-model-catalog", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusApplied { t.Fatalf("expected status=%q, got %q changes=%+v", configrefresh.StatusApplied, result.Status, result.Changes) } found := map[string]bool{} for _, change := range result.Changes { if change.Class == configrefresh.StatusApplied { found[change.Path] = true } } for _, path := range []string{ `models["qwen3.6:35b"].context_window_tokens`, `models["qwen3.6:35b"].default_max_tokens`, `models["qwen3.6:35b"].min_max_tokens`, `models["qwen3.6:35b"].default_thinking_token_budget`, `models["qwen3.6:35b"].providers`, } { if !found[path] { t.Fatalf("model catalog change %s not found in %+v", path, result.Changes) } } } func TestClassifyProviderStructuralChangeRestartRequired(t *testing.T) { dir := t.TempDir() currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML) candidateYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "vllm" category: "api" adapter: "cli" models: ["llama3.1"] capacity: 2 max_queue: 4 queue_timeout_ms: 5000 ` candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-provider-structural", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusRestartRequired { t.Fatalf("expected status=%q, got %q changes=%+v", configrefresh.StatusRestartRequired, result.Status, result.Changes) } found := false for _, change := range result.Changes { if change.Path == `nodes[].providers["prov-a"].type` && change.Class == configrefresh.StatusRestartRequired { found = true } } if !found { t.Fatalf("provider structural type change not found in %+v", result.Changes) } } // TestRefreshInvalidProviderRejected verifies S09: invalid provider config is classified as rejected. func TestRefreshInvalidProviderRejected(t *testing.T) { dir := t.TempDir() currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML) // Invalid: shared validator rejects enabled openai_compat without endpoint. invalidProviderYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: openai_compat: enabled: true endpoint: "" ` invalidPath := writeYAML(t, dir, "invalid_provider.yaml", invalidProviderYAML) current := buildNormalizedCurrent(t, currentPath) ctx := context.Background() result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: invalidPath, RequestID: "test-s09-provider", }) if err != nil { t.Fatalf("Evaluate should not return error for rejected config: %v", err) } if result.Status != configrefresh.StatusRejected { t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusRejected, result.Status, result.Summary) } if candidate != nil { t.Errorf("expected nil candidate for rejected result") } } // TestClassifyServeNormalizedRefreshApplied verifies the archived bug: // when current config is serve-normalized (logging.path absolute, // bootstrap.artifact_dir absolute) and candidate uses source-level defaults // (empty logging.path, relative bootstrap.artifact_dir) with only provider // capacity changes, the result must be applied, NOT restart_required. func TestClassifyServeNormalizedRefreshApplied(t *testing.T) { dir := t.TempDir() // Source-level YAML with empty logging.path and relative artifact_dir. // Only capacity differs (2 → 8). sourceYAML := ` server: listen: "0.0.0.0:9090" bootstrap: artifact_dir: artifacts logging: level: info nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 max_queue: 4 queue_timeout_ms: 5000 ` // Candidate: only capacity changes to 8; logging/artifact_dir are same source-level defaults. candidateYAML := ` server: listen: "0.0.0.0:9090" bootstrap: artifact_dir: artifacts logging: level: info nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 8 max_queue: 4 queue_timeout_ms: 5000 ` currentPath := writeYAML(t, dir, "current.yaml", sourceYAML) candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) // Build current as serve-normalized: explicit absolute paths. current := buildNormalizedCurrent(t, currentPath) // Verify that the current config actually has normalized paths. if current.Logging.Path == "" { t.Fatalf("expected normalized logging.path, got empty") } if current.Bootstrap.ArtifactDir == "" { t.Fatalf("expected normalized bootstrap.artifact_dir, got empty") } // Evaluate the candidate via LoadCandidate (which now applies normalization). ctx := context.Background() result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-serve-normalized", }) if err != nil { t.Fatalf("Evaluate: %v", err) } // Key assertion: capacity-only refresh must NOT be restart_required. if result.Status != configrefresh.StatusApplied { t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary) } // Ensure no restart_required changes for logging or bootstrap.artifact_dir. for _, c := range result.Changes { if c.Class == configrefresh.StatusRestartRequired { t.Errorf("unexpected restart_required change for capacity-only refresh: %s (prev=%s, next=%s)", c.Path, c.Previous, c.Next) } } // Verify the capacity change is captured as applied. foundCapacity := false for _, c := range result.Changes { if c.Path == `nodes[].providers["prov-a"].capacity` && c.Class == configrefresh.StatusApplied { foundCapacity = true if c.Previous != "2" || c.Next != "8" { t.Errorf("expected capacity change 2→8, got %s→%s", c.Previous, c.Next) } } } if !foundCapacity { t.Fatalf("capacity change not found in applied changes: %+v", result.Changes) } // Verify the candidate is also normalized. if candidate == nil { t.Fatalf("expected non-nil candidate") } if candidate.Logging.Path == "" { t.Fatalf("expected normalized candidate logging.path, got empty") } } // TestClassifyExplicitRelativeLoggingPathNoRestartRequired verifies that when // both current and candidate use the same explicit relative logging.path // (e.g. "./logs/edge.log") and only provider capacity differs, the refresh // is classified as applied – NOT restart_required. // // This is the regression test for the archived bug where applyRuntimeNormalization // called filepath.Clean on explicit relative paths, producing a false diff // against the serve-side value that preserves "./logs/edge.log" unchanged. func TestClassifyExplicitRelativeLoggingPathNoRestartRequired(t *testing.T) { dir := t.TempDir() // Current config with explicit relative logging.path – mimics what serve // holds in memory after loading YAML with logging.path: "./logs/edge.log". currentYAML := ` server: listen: "0.0.0.0:9090" logging: path: "./logs/edge.log" level: info nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 max_queue: 4 queue_timeout_ms: 5000 ` // Candidate: same explicit relative logging.path, only capacity changes. candidateYAML := ` server: listen: "0.0.0.0:9090" logging: path: "./logs/edge.log" level: info nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 8 max_queue: 4 queue_timeout_ms: 5000 ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) // buildNormalizedCurrent preserves the explicit relative logging.path. current := buildNormalizedCurrent(t, currentPath) // Verify the current config still has the explicit relative path. if current.Logging.Path != "./logs/edge.log" { t.Fatalf("expected current logging.path to remain './logs/edge.log', got %q", current.Logging.Path) } ctx := context.Background() result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-explicit-relative-logpath", }) if err != nil { t.Fatalf("Evaluate: %v", err) } // Key assertion: capacity-only refresh with same explicit relative path must be applied. if result.Status != configrefresh.StatusApplied { t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary) } // Ensure no restart_required changes at all. for _, c := range result.Changes { if c.Class == configrefresh.StatusRestartRequired { t.Errorf("unexpected restart_required change for capacity-only refresh: %s (prev=%s, next=%s)", c.Path, c.Previous, c.Next) } } // Verify the capacity change is captured as applied. foundCapacity := false for _, c := range result.Changes { if c.Path == `nodes[].providers["prov-a"].capacity` && c.Class == configrefresh.StatusApplied { foundCapacity = true if c.Previous != "2" || c.Next != "8" { t.Errorf("expected capacity change 2→8, got %s→%s", c.Previous, c.Next) } } } if !foundCapacity { t.Fatalf("capacity change not found in applied changes: %+v", result.Changes) } // Candidate should also preserve the explicit relative path. if candidate == nil { t.Fatalf("expected non-nil candidate") } if candidate.Logging.Path != "./logs/edge.log" { t.Errorf("expected candidate logging.path to remain './logs/edge.log', got %q", candidate.Logging.Path) } } // TestClassifyDryRunTwiceNoDiff verifies that after apply, re-running dry-run // on the same candidate produces no diff. func TestClassifyDryRunTwiceNoDiff(t *testing.T) { dir := t.TempDir() baseYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 ` candidateYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 4 ` currentPath := writeYAML(t, dir, "current.yaml", baseYAML) candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) ctx := context.Background() // First dry-run: should detect capacity change. result1, cand1, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-first-dryrun", }) if err != nil { t.Fatalf("first Evaluate: %v", err) } if result1.Status != configrefresh.StatusApplied { t.Fatalf("first dry-run: expected status=%q, got %q", configrefresh.StatusApplied, result1.Status) } // Second dry-run with the same candidate loaded again. // The candidate path still exists, so LoadCandidate re-reads the same YAML. result2, cand2, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-second-dryrun", }) if err != nil { t.Fatalf("second Evaluate: %v", err) } // Both results should be identical (same changes). if len(result1.Changes) != len(result2.Changes) { t.Fatalf("expected same number of changes, got %d vs %d: result1=%+v result2=%+v", len(result1.Changes), len(result2.Changes), result1.Changes, result2.Changes) } // Both candidates should be normalized identically. if cand1 == nil || cand2 == nil { t.Fatalf("expected non-nil candidates") } if cand1.Logging.Path != cand2.Logging.Path { t.Fatalf("candidate logging.path mismatch: %s vs %s", cand1.Logging.Path, cand2.Logging.Path) } } // TestClassifyNodeRuntimeConcurrencyApplied verifies S15: node runtime // concurrency is applied live. func TestClassifyNodeRuntimeConcurrencyApplied(t *testing.T) { dir := t.TempDir() currentYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true runtime: concurrency: 2 ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) concurrencyOnlyYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true runtime: concurrency: 4 ` concurrencyOnlyPath := writeYAML(t, dir, "concurrency.yaml", concurrencyOnlyYAML) current := buildNormalizedCurrent(t, currentPath) ctx := context.Background() // concurrency-only change → applied. result, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: concurrencyOnlyPath, RequestID: "test-s15-conc", }) if err != nil { t.Fatalf("Evaluate concurrency: %v", err) } if result.Status != configrefresh.StatusApplied { t.Fatalf("expected concurrency change status=applied, got %q (summary: %s)", result.Status, result.Summary) } foundConc := false for _, c := range result.Changes { if c.Path == `nodes["node-1"].runtime.concurrency` { foundConc = true if c.Class != configrefresh.StatusApplied { t.Errorf("expected concurrency class=applied, got %q", c.Class) } } if c.Class == configrefresh.StatusRestartRequired { t.Errorf("unexpected restart_required change for concurrency-only diff: %s", c.Path) } } if !foundConc { t.Errorf("concurrency change path not found in: %+v", result.Changes) } } // TestClassifyProviderEnabledLiveApply verifies that toggling providers[].enabled // is classified as StatusApplied (live apply, no restart required). func TestClassifyProviderEnabledLiveApply(t *testing.T) { dir := t.TempDir() currentYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) // Candidate disables prov-a. candidateYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 enabled: false ` candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-enabled-liveapply", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusApplied { t.Fatalf("expected status=applied for enabled toggle, got %q", result.Status) } foundEnabled := false for _, c := range result.Changes { if c.Path == `nodes[].providers["prov-a"].enabled` { foundEnabled = true if c.Class != configrefresh.StatusApplied { t.Errorf("providers[].enabled class: got %q, want applied", c.Class) } if c.Previous != "true" || c.Next != "false" { t.Errorf("providers[].enabled change: prev=%q next=%q", c.Previous, c.Next) } } if c.Class == configrefresh.StatusRestartRequired { t.Errorf("unexpected restart_required change for enabled-only diff: %s", c.Path) } } if !foundEnabled { t.Errorf("providers[].enabled change path not found in: %+v", result.Changes) } } // TestClassifyProviderFirstEndpointOnlyRestartRequired verifies that changing // only the provider endpoint (type/category unchanged) is classified as // restart_required and surfaces the correct path. func TestClassifyProviderFirstEndpointOnlyRestartRequired(t *testing.T) { dir := t.TempDir() currentYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" providers: - id: "prov-oc" type: "openai_compat" category: "api" endpoint: "http://127.0.0.1:8000/v1" models: ["model-a"] capacity: 2 ` // Only endpoint changes — type, category, models, capacity are identical. candidateYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" providers: - id: "prov-oc" type: "openai_compat" category: "api" endpoint: "http://127.0.0.1:9000/v1" models: ["model-a"] capacity: 2 ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-endpoint-only", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusRestartRequired { t.Fatalf("expected status=restart_required for endpoint-only change, got %q; changes=%+v", result.Status, result.Changes) } found := false for _, c := range result.Changes { if c.Path == `nodes[].providers["prov-oc"].endpoint` && c.Class == configrefresh.StatusRestartRequired { found = true } } if !found { t.Errorf("expected restart_required change for providers[].endpoint; changes=%+v", result.Changes) } } // TestClassifyProviderFirstHeadersChangeRestartRequired verifies that changing // a provider headers map (type/category unchanged) is classified as // restart_required — covers map deep-diff path. func TestClassifyProviderFirstHeadersChangeRestartRequired(t *testing.T) { dir := t.TempDir() currentYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" providers: - id: "prov-oc" type: "openai_compat" category: "api" endpoint: "http://127.0.0.1:8000/v1" headers: Authorization: "Bearer old-token" models: ["model-a"] capacity: 2 ` // Only headers change. candidateYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" providers: - id: "prov-oc" type: "openai_compat" category: "api" endpoint: "http://127.0.0.1:8000/v1" headers: Authorization: "Bearer new-token" models: ["model-a"] capacity: 2 ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-headers-only", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusRestartRequired { t.Fatalf("expected status=restart_required for headers change, got %q; changes=%+v", result.Status, result.Changes) } found := false for _, c := range result.Changes { if c.Path == `nodes[].providers["prov-oc"].headers` && c.Class == configrefresh.StatusRestartRequired { found = true } } if !found { t.Errorf("expected restart_required change for providers[].headers; changes=%+v", result.Changes) } } func TestClassifyProviderFirstExecutionFieldRestartRequired(t *testing.T) { dir := t.TempDir() currentYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: vllm: enabled: true endpoint: "http://127.0.0.1:8000/v1" providers: - id: "prov-vllm" type: "vllm" category: "api" endpoint: "http://127.0.0.1:8000/v1" models: ["model-a"] capacity: 2 ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) // Candidate changes endpoint (provider-first execution field) → restart required. candidateYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: vllm: enabled: true endpoint: "http://127.0.0.1:8080/v1" providers: - id: "prov-vllm" type: "openai_compat" category: "api" endpoint: "http://127.0.0.1:8080/v1" models: ["model-a"] capacity: 2 ` candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-provfirst-restart", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusRestartRequired { t.Fatalf("expected status=restart_required for provider type change, got %q", result.Status) } hasTypeRestart := false for _, c := range result.Changes { if c.Path == `nodes[].providers["prov-vllm"].type` && c.Class == configrefresh.StatusRestartRequired { hasTypeRestart = true } } if !hasTypeRestart { t.Errorf("expected restart_required for providers[].type change; changes: %+v", result.Changes) } } // TestClassifyProviderPriorityApplied verifies that provider priority change // is classified as StatusApplied (live apply, no restart required). func TestClassifyProviderPriorityApplied(t *testing.T) { dir := t.TempDir() currentYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 priority: 5 ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) // Candidate only changes priority. candidateYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 priority: 1 ` candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-priority-applied", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusApplied { t.Fatalf("expected status=applied for priority-only change, got %q (summary: %s)", result.Status, result.Summary) } foundPriority := false for _, c := range result.Changes { if c.Path == `nodes[].providers["prov-a"].priority` { foundPriority = true if c.Class != configrefresh.StatusApplied { t.Errorf("providers[].priority class: got %q, want applied", c.Class) } if c.Previous != "5" || c.Next != "1" { t.Errorf("providers[].priority change: prev=%q next=%q", c.Previous, c.Next) } } if c.Class == configrefresh.StatusRestartRequired { t.Errorf("unexpected restart_required change for priority-only diff: %s", c.Path) } } if !foundPriority { t.Errorf("providers[].priority change path not found in: %+v", result.Changes) } } // TestClassifyProviderLongContextCapacityApplied verifies that provider // total_context_tokens and long_context_capacity changes are classified as // live-apply (StatusApplied), consistent with capacity/priority. func TestClassifyProviderLongContextCapacityApplied(t *testing.T) { dir := t.TempDir() currentYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 total_context_tokens: 262144 long_context_capacity: 1 ` currentPath := writeYAML(t, dir, "current.yaml", currentYAML) // Candidate only changes the two long-context fields. candidateYAML := ` server: listen: "0.0.0.0:9090" nodes: - id: "node-1" alias: "n1" token: "tok-1" adapters: cli: enabled: true providers: - id: "prov-a" type: "ollama" category: "local_inference" adapter: "cli" models: ["llama3.1"] capacity: 2 total_context_tokens: 524288 long_context_capacity: 2 ` candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML) current := buildNormalizedCurrent(t, currentPath) result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{ Mode: configrefresh.ModeDryRun, ConfigPath: candidatePath, RequestID: "test-long-capacity-applied", }) if err != nil { t.Fatalf("Evaluate: %v", err) } if result.Status != configrefresh.StatusApplied { t.Fatalf("expected status=applied for long-context-only change, got %q (summary: %s)", result.Status, result.Summary) } foundTotal := false foundLong := false for _, c := range result.Changes { switch c.Path { case `nodes[].providers["prov-a"].total_context_tokens`: foundTotal = true if c.Class != configrefresh.StatusApplied { t.Errorf("total_context_tokens class: got %q, want applied", c.Class) } if c.Previous != "262144" || c.Next != "524288" { t.Errorf("total_context_tokens change: prev=%q next=%q", c.Previous, c.Next) } case `nodes[].providers["prov-a"].long_context_capacity`: foundLong = true if c.Class != configrefresh.StatusApplied { t.Errorf("long_context_capacity class: got %q, want applied", c.Class) } if c.Previous != "1" || c.Next != "2" { t.Errorf("long_context_capacity change: prev=%q next=%q", c.Previous, c.Next) } } if c.Class == configrefresh.StatusRestartRequired { t.Errorf("unexpected restart_required change for long-context-only diff: %s", c.Path) } } if !foundTotal { t.Errorf("providers[].total_context_tokens change path not found in: %+v", result.Changes) } if !foundLong { t.Errorf("providers[].long_context_capacity change path not found in: %+v", result.Changes) } }