iop/apps/edge/internal/configrefresh/classify_test.go
toki 695df72eac feat: runtime reconnect config refresh - node drain config refresh
- Add PHASE.md for cli-agent-group-grade-routing milestone
- Add SDD for automation-runtime-bridge
- Update edge configrefresh classify logic and tests
- Update node config_set, node.go, and run_manager for runtime config refresh
- Archive old plan/review docs for 06+05_node_drain_runtime_config
2026-06-23 13:33:21 +09:00

831 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
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"
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"
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 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"
models: ["llama3.1"]
capacity: 2
- id: "prov-b"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
models:
- id: "qwen3.6:35b"
display_name: "Qwen"
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"
models: ["llama3.1"]
capacity: 2
- id: "prov-b"
type: "ollama"
category: "local_inference"
models: ["llama3.1"]
capacity: 2
models:
- id: "qwen3.6:35b"
display_name: "Qwen"
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 := false
for _, change := range result.Changes {
if change.Path == `models["qwen3.6:35b"].providers` && change.Class == configrefresh.StatusApplied {
found = true
}
}
if !found {
t.Fatalf("model catalog providers change not found in %+v", 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"
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"
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"
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"
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"
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"
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"
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)
}
}
// TestClassifyNodeRuntimeConcurrencyAppliedWorkspaceRestartRequired verifies
// S15: node runtime tuning is classified per field. concurrency is applied live
// while workspace_root requires a node restart.
func TestClassifyNodeRuntimeConcurrencyAppliedWorkspaceRestartRequired(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
workspace_root: "/var/lib/iop/ws-a"
`
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
workspace_root: "/var/lib/iop/ws-a"
`
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)
}
// workspace_root change → restart_required.
workspaceYAML := `
server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-1"
alias: "n1"
token: "tok-1"
adapters:
cli:
enabled: true
runtime:
concurrency: 2
workspace_root: "/var/lib/iop/ws-b"
`
workspacePath := writeYAML(t, dir, "workspace.yaml", workspaceYAML)
wsResult, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
Mode: configrefresh.ModeDryRun,
ConfigPath: workspacePath,
RequestID: "test-s15-ws",
})
if err != nil {
t.Fatalf("Evaluate workspace: %v", err)
}
if wsResult.Status != configrefresh.StatusRestartRequired {
t.Fatalf("expected workspace_root change status=restart_required, got %q", wsResult.Status)
}
foundWS := false
for _, c := range wsResult.Changes {
if c.Path == `nodes["node-1"].runtime.workspace_root` {
foundWS = true
if c.Class != configrefresh.StatusRestartRequired {
t.Errorf("expected workspace_root class=restart_required, got %q", c.Class)
}
}
}
if !foundWS {
t.Errorf("workspace_root change path not found in: %+v", wsResult.Changes)
}
foundRestartPath := false
for _, p := range wsResult.RestartRequiredPaths {
if p == `nodes["node-1"].runtime.workspace_root` {
foundRestartPath = true
}
}
if !foundRestartPath {
t.Errorf("workspace_root not in RestartRequiredPaths: %v", wsResult.RestartRequiredPaths)
}
}