package configrefresh import ( "context" "fmt" "os" "path/filepath" "reflect" "sort" "strings" "iop/apps/edge/internal/edgevalidate" "iop/packages/go/config" ) // applyRuntimeNormalization normalizes candidate config so that classification // uses the same effective values as a running serve process. func applyRuntimeNormalization(cfg *config.EdgeConfig) { // Normalize logging.path – must match edgecmd.ResolveEdgeLogPath exactly: // - empty → resolve to binary-dir/logs/edge.log // - absolute → preserve exactly // - relative → preserve exactly (include ./ segments) // Serve keeps explicit relative paths like "./logs/edge.log" unchanged, // so classifying against a cleaned value produces false restart_required. if cfg.Logging.Path == "" { cfg.Logging.Path = defaultEdgeLogPath() } // NOTE: explicit absolute / relative paths are intentionally left untouched. // Normalize bootstrap.artifact_dir – resolve relative paths to absolute. cfg.Bootstrap.ArtifactDir = resolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir) } func defaultEdgeLogPath() 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 resolveBootstrapArtifactDir(dir string) string { if dir == "" { dir = "artifacts" } if filepath.IsAbs(dir) { return dir } return filepath.Join(binaryDir(), dir) } func binaryDir() string { if exe, err := os.Executable(); err == nil { return filepath.Dir(exe) } if wd, err := os.Getwd(); err == nil { return wd } return "." } // LoadCandidate loads and validates an EdgeConfig from the given path, then // applies the same runtime normalization that iop-edge serve uses. This // ensures that classification compares effective (normalized) values and // prevents false restart_required results when source-level YAML is // identical but paths are relative or empty. func LoadCandidate(path string) (*config.EdgeConfig, error) { cfg, err := config.LoadEdge(path) if err != nil { return nil, err } if err := edgevalidate.ValidateEdgeConfig(cfg); err != nil { return nil, err } applyRuntimeNormalization(cfg) return cfg, nil } // providerKey identifies a provider entry for diffing. type providerKey struct { NodeKey string Type string Category config.Category Adapter string Models []string Health string Capacity int TotalContextTokens int LongContextCapacity int Priority int LifecycleCapabilities []string // Enabled tracks the effective enabled state for live-apply detection. // Not used for restart-required structural comparison. Enabled bool // Provider-First execution fields (G06) — all are restart-required on change // because they alter what the Node adapter connects to or how it runs. Provider string Endpoint string BaseURL string Headers map[string]string Command string Args []string Env []string Mode string ResumeArgs []string OutputFormat string ContextSize int RequestTimeoutMS int } func buildProviderIndex(cfg *config.EdgeConfig) map[string]providerKey { idx := make(map[string]providerKey) for i, node := range cfg.Nodes { nodeKey := nodeIdentity(node, i) for _, p := range node.Providers { idx[p.ID] = providerKey{ NodeKey: nodeKey, Type: p.Type, Category: p.Category, Adapter: p.Adapter, Models: append([]string(nil), p.Models...), Health: p.Health, Capacity: p.Capacity, TotalContextTokens: p.TotalContextTokens, LongContextCapacity: p.LongContextCapacity, Priority: p.Priority, LifecycleCapabilities: append([]string(nil), p.LifecycleCapabilities...), Enabled: config.ProviderEnabled(p), Provider: p.Provider, Endpoint: p.Endpoint, BaseURL: p.BaseURL, Headers: cloneStringMap(p.Headers), Command: p.Command, Args: append([]string(nil), p.Args...), Env: append([]string(nil), p.Env...), Mode: p.Mode, ResumeArgs: append([]string(nil), p.ResumeArgs...), OutputFormat: p.OutputFormat, ContextSize: p.ContextSize, RequestTimeoutMS: p.RequestTimeoutMS, } } } return idx } type nodeKey struct { Alias string Token string AgentKind string Adapters config.AdaptersConf Runtime config.RuntimeConf } func buildNodeIndex(cfg *config.EdgeConfig) map[string]nodeKey { idx := make(map[string]nodeKey, len(cfg.Nodes)) for i, node := range cfg.Nodes { idx[nodeIdentity(node, i)] = nodeKey{ Alias: node.Alias, Token: node.Token, AgentKind: node.AgentKind, Adapters: node.Adapters, Runtime: node.Runtime, } } return idx } func nodeIdentity(node config.NodeDefinition, index int) string { if node.ID != "" { return node.ID } if node.Alias != "" { return node.Alias } return fmt.Sprintf("#%d", index) } func buildModelIndex(cfg *config.EdgeConfig) map[string]config.ModelCatalogEntry { idx := make(map[string]config.ModelCatalogEntry, len(cfg.Models)) for _, model := range cfg.Models { idx[model.ID] = model } return idx } func appendIfChanged[T comparable](changes *[]Change, path string, class Status, previous, next T) { if previous == next { return } *changes = append(*changes, Change{ Path: path, Class: class, Previous: fmt.Sprint(previous), Next: fmt.Sprint(next), }) } func appendDeepIfChanged(changes *[]Change, path string, class Status, previous, next interface{}) { if reflect.DeepEqual(previous, next) { return } *changes = append(*changes, Change{ Path: path, Class: class, Previous: fmt.Sprintf("%v", previous), Next: fmt.Sprintf("%v", next), }) } func appendEdgeChanges(changes *[]Change, current, candidate *config.EdgeConfig) { appendIfChanged(changes, "edge.id", StatusRestartRequired, current.Edge.ID, candidate.Edge.ID) appendIfChanged(changes, "edge.name", StatusRestartRequired, current.Edge.Name, candidate.Edge.Name) appendIfChanged(changes, "server.listen", StatusRestartRequired, current.Server.Listen, candidate.Server.Listen) appendIfChanged(changes, "server.advertise_host", StatusRestartRequired, current.Server.AdvertiseHost, candidate.Server.AdvertiseHost) appendIfChanged(changes, "bootstrap.listen", StatusRestartRequired, current.Bootstrap.Listen, candidate.Bootstrap.Listen) appendIfChanged(changes, "bootstrap.artifact_base_url", StatusRestartRequired, current.Bootstrap.ArtifactBaseURL, candidate.Bootstrap.ArtifactBaseURL) appendIfChanged(changes, "bootstrap.artifact_dir", StatusRestartRequired, current.Bootstrap.ArtifactDir, candidate.Bootstrap.ArtifactDir) appendDeepIfChanged(changes, "tls", StatusRestartRequired, current.TLS, candidate.TLS) appendDeepIfChanged(changes, "logging", StatusRestartRequired, current.Logging, candidate.Logging) appendIfChanged(changes, "metrics.port", StatusRestartRequired, current.Metrics.Port, candidate.Metrics.Port) appendDeepIfChanged(changes, "console", StatusRestartRequired, current.Console, candidate.Console) appendDeepIfChanged(changes, "control_plane", StatusRestartRequired, current.ControlPlane, candidate.ControlPlane) appendDeepIfChanged(changes, "refresh", StatusRestartRequired, current.Refresh, candidate.Refresh) appendDeepIfChanged(changes, "openai", StatusRestartRequired, current.OpenAI, candidate.OpenAI) appendDeepIfChanged(changes, "a2a", StatusRestartRequired, current.A2A, candidate.A2A) appendIfChanged(changes, "long_context_threshold_tokens", StatusApplied, current.LongContextThresholdTokens, candidate.LongContextThresholdTokens) } func appendNodeChanges(changes *[]Change, current, candidate *config.EdgeConfig) { currentNodes := buildNodeIndex(current) candidateNodes := buildNodeIndex(candidate) for key, cur := range currentNodes { next, exists := candidateNodes[key] if !exists { *changes = append(*changes, Change{ Path: fmt.Sprintf("nodes[%q]", key), Class: StatusRestartRequired, Previous: "present", Next: "absent", }) continue } appendIfChanged(changes, fmt.Sprintf("nodes[%q].alias", key), StatusRestartRequired, cur.Alias, next.Alias) appendIfChanged(changes, fmt.Sprintf("nodes[%q].token", key), StatusRestartRequired, cur.Token, next.Token) appendIfChanged(changes, fmt.Sprintf("nodes[%q].agent_kind", key), StatusRestartRequired, cur.AgentKind, next.AgentKind) appendDeepIfChanged(changes, fmt.Sprintf("nodes[%q].adapters", key), StatusRestartRequired, cur.Adapters, next.Adapters) // Legacy runtime concurrency metadata is live-applyable for compat. // Runtime admission is owned by provider/resource capacity. appendIfChanged(changes, fmt.Sprintf("nodes[%q].runtime.concurrency", key), StatusApplied, cur.Runtime.Concurrency, next.Runtime.Concurrency) } for key := range candidateNodes { if _, exists := currentNodes[key]; !exists { *changes = append(*changes, Change{ Path: fmt.Sprintf("nodes[%q]", key), Class: StatusRestartRequired, Previous: "absent", Next: "present", }) } } } func appendProviderStructuralChanges(changes *[]Change, current, candidate map[string]providerKey) { for provID, cur := range current { next, exists := candidate[provID] if !exists { *changes = append(*changes, Change{ Path: fmt.Sprintf("nodes[].providers[%q]", provID), Class: StatusRestartRequired, Previous: "present", Next: "absent", }) continue } appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].node", provID), StatusRestartRequired, cur.NodeKey, next.NodeKey) appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].type", provID), StatusRestartRequired, cur.Type, next.Type) appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].category", provID), StatusRestartRequired, cur.Category, next.Category) appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].adapter", provID), StatusRestartRequired, cur.Adapter, next.Adapter) appendDeepIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].models", provID), StatusRestartRequired, cur.Models, next.Models) appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].health", provID), StatusRestartRequired, cur.Health, next.Health) appendDeepIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].lifecycle_capabilities", provID), StatusRestartRequired, cur.LifecycleCapabilities, next.LifecycleCapabilities) appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].provider", provID), StatusRestartRequired, cur.Provider, next.Provider) appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].endpoint", provID), StatusRestartRequired, cur.Endpoint, next.Endpoint) appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].base_url", provID), StatusRestartRequired, cur.BaseURL, next.BaseURL) appendDeepIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].headers", provID), StatusRestartRequired, cur.Headers, next.Headers) appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].command", provID), StatusRestartRequired, cur.Command, next.Command) appendDeepIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].args", provID), StatusRestartRequired, cur.Args, next.Args) appendDeepIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].env", provID), StatusRestartRequired, cur.Env, next.Env) appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].mode", provID), StatusRestartRequired, cur.Mode, next.Mode) appendDeepIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].resume_args", provID), StatusRestartRequired, cur.ResumeArgs, next.ResumeArgs) appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].output_format", provID), StatusRestartRequired, cur.OutputFormat, next.OutputFormat) appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].context_size", provID), StatusRestartRequired, cur.ContextSize, next.ContextSize) appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].request_timeout_ms", provID), StatusRestartRequired, cur.RequestTimeoutMS, next.RequestTimeoutMS) } for provID := range candidate { if _, exists := current[provID]; !exists { *changes = append(*changes, Change{ Path: fmt.Sprintf("nodes[].providers[%q]", provID), Class: StatusRestartRequired, Previous: "absent", Next: "present", }) } } } func appendProviderAppliedChanges(changes *[]Change, current, candidate map[string]providerKey) { for provID, next := range candidate { cur, exists := current[provID] if !exists { continue } appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].capacity", provID), StatusApplied, cur.Capacity, next.Capacity) appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].total_context_tokens", provID), StatusApplied, cur.TotalContextTokens, next.TotalContextTokens) appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].long_context_capacity", provID), StatusApplied, cur.LongContextCapacity, next.LongContextCapacity) appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].priority", provID), StatusApplied, cur.Priority, next.Priority) // Provider-First execution fields (provider_pool policy is no longer // reported via per-provider path; canonical path below owns queue policy). appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].enabled", provID), StatusApplied, cur.Enabled, next.Enabled) } } // appendProviderPoolPolicyApplied records the effective root provider_pool // queue policy diff as live-apply. The policy owner is the Edge root // provider_pool, not per-provider legacy fields. func appendProviderPoolPolicyApplied(changes *[]Change, current, candidate *config.EdgeConfig) { appendIfChanged(changes, "provider_pool.max_queue", StatusApplied, current.ProviderPool.MaxQueue, candidate.ProviderPool.MaxQueue) appendIfChanged(changes, "provider_pool.queue_timeout_ms", StatusApplied, current.ProviderPool.QueueTimeoutMS, candidate.ProviderPool.QueueTimeoutMS) } func appendProviderChanges(changes *[]Change, current, candidate *config.EdgeConfig) { currentProviders := buildProviderIndex(current) candidateProviders := buildProviderIndex(candidate) appendProviderStructuralChanges(changes, currentProviders, candidateProviders) appendProviderAppliedChanges(changes, currentProviders, candidateProviders) // Effective root provider_pool queue policy diff. appendProviderPoolPolicyApplied(changes, current, candidate) } func appendModelChanges(changes *[]Change, current, candidate *config.EdgeConfig) { currentModels := buildModelIndex(current) candidateModels := buildModelIndex(candidate) for modelID, cur := range currentModels { next, exists := candidateModels[modelID] if !exists { *changes = append(*changes, Change{ Path: fmt.Sprintf("models[%q]", modelID), Class: StatusApplied, Previous: "present", Next: "absent", }) continue } appendIfChanged(changes, fmt.Sprintf("models[%q].display_name", modelID), StatusApplied, cur.DisplayName, next.DisplayName) appendIfChanged(changes, fmt.Sprintf("models[%q].context_window_tokens", modelID), StatusApplied, cur.ContextWindowTokens, next.ContextWindowTokens) appendIfChanged(changes, fmt.Sprintf("models[%q].default_max_tokens", modelID), StatusApplied, cur.DefaultMaxTokens, next.DefaultMaxTokens) appendIfChanged(changes, fmt.Sprintf("models[%q].min_max_tokens", modelID), StatusApplied, cur.MinMaxTokens, next.MinMaxTokens) appendIfChanged(changes, fmt.Sprintf("models[%q].default_thinking_token_budget", modelID), StatusApplied, cur.DefaultThinkingTokenBudget, next.DefaultThinkingTokenBudget) appendDeepIfChanged(changes, fmt.Sprintf("models[%q].providers", modelID), StatusApplied, cur.Providers, next.Providers) } for modelID := range candidateModels { if _, exists := currentModels[modelID]; !exists { *changes = append(*changes, Change{ Path: fmt.Sprintf("models[%q]", modelID), Class: StatusApplied, Previous: "absent", Next: "present", }) } } } func resultFromChanges(changes []Change) Result { sort.SliceStable(changes, func(i, j int) bool { if changes[i].Path == changes[j].Path { return changes[i].Class < changes[j].Class } return changes[i].Path < changes[j].Path }) overallStatus := StatusApplied summary := "no changes detected" for _, change := range changes { if change.Class == StatusRestartRequired { overallStatus = StatusRestartRequired summary = "some changes require a process restart" break } } if overallStatus == StatusApplied && len(changes) > 0 { summary = "all changes can be applied without restart" } result := Result{ Status: overallStatus, Changes: changes, Summary: summary, } deriveReport(&result) return result } // Classify computes the diff between current and candidate configs and returns // the classified change list and an overall status. func Classify(current, candidate *config.EdgeConfig) Result { var changes []Change appendEdgeChanges(&changes, current, candidate) appendNodeChanges(&changes, current, candidate) appendProviderChanges(&changes, current, candidate) appendModelChanges(&changes, current, candidate) return resultFromChanges(changes) } // deriveReport populates the ops-report fields (changed node/provider/model ids // and restart-required paths) from the classified change list. All slices are // initialized non-nil so the JSON response stays stable for ops assertions. func deriveReport(r *Result) { nodes := newStringSet() providers := newStringSet() models := newStringSet() var restartPaths []string for _, c := range r.Changes { if c.Class == StatusRestartRequired { restartPaths = append(restartPaths, c.Path) } // Provider paths are nested under nodes[].providers[...]; check them // before the broader nodes[ prefix so they are attributed correctly. if id, ok := extractBracketID(c.Path, "providers["); ok { providers.add(id) continue } if id, ok := extractBracketID(c.Path, "nodes["); ok { nodes.add(id) continue } if id, ok := extractBracketID(c.Path, "models["); ok { models.add(id) } } r.ChangedNodes = nodes.sorted() r.ChangedProviders = providers.sorted() r.ChangedModels = models.sorted() sort.Strings(restartPaths) if restartPaths == nil { restartPaths = []string{} } r.RestartRequiredPaths = restartPaths } // extractBracketID returns the quoted id immediately following prefix in path, // e.g. extractBracketID(`nodes["node-1"].alias`, "nodes[") returns "node-1". func extractBracketID(path, prefix string) (string, bool) { i := strings.Index(path, prefix) if i < 0 { return "", false } rest := path[i+len(prefix):] if len(rest) == 0 || rest[0] != '"' { return "", false } rest = rest[1:] j := strings.IndexByte(rest, '"') if j <= 0 { return "", false } return rest[:j], true } func cloneStringMap(m map[string]string) map[string]string { if m == nil { return nil } out := make(map[string]string, len(m)) for k, v := range m { out[k] = v } return out } type stringSet struct { m map[string]struct{} } func newStringSet() stringSet { return stringSet{m: make(map[string]struct{})} } func (s stringSet) add(v string) { s.m[v] = struct{}{} } func (s stringSet) sorted() []string { out := make([]string, 0, len(s.m)) for v := range s.m { out = append(out, v) } sort.Strings(out) return out } // Evaluate loads the candidate config from req.ConfigPath, validates it, and // classifies changes against current. On success it also returns the loaded // candidate so the caller can apply mutable changes in apply mode. func Evaluate(_ context.Context, current *config.EdgeConfig, req Request) (Result, *config.EdgeConfig, error) { if req.Mode != ModeDryRun && req.Mode != ModeApply { return RejectedResult(req, fmt.Sprintf("invalid mode %q: must be dry_run or apply", req.Mode)), nil, nil } candidate, err := LoadCandidate(req.ConfigPath) if err != nil { return RejectedResult(req, fmt.Sprintf("load candidate config: %v", err)), nil, nil } result := Classify(current, candidate) result.Mode = req.Mode result.RequestID = req.RequestID if result.Changes == nil { result.Changes = []Change{} } return result, candidate, nil }