From 480cd714b2527f6eb6295b1c749b47b830780b05 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Tue, 2 Dec 2025 21:38:54 +0800 Subject: [PATCH 1/2] feat(config): add pruning of stale YAML mapping keys during config save --- internal/config/config.go | 72 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 11610462..16c8b4dc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -599,6 +599,7 @@ func SaveConfigPreserveComments(configFile string, cfg *Config) error { // Remove deprecated auth block before merging to avoid persisting it again. removeMapKey(original.Content[0], "auth") removeLegacyOpenAICompatAPIKeys(original.Content[0]) + pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-excluded-models") // Merge generated into original in-place, preserving comments/order of existing nodes. mergeMappingPreserve(original.Content[0], generated.Content[0]) @@ -797,6 +798,10 @@ func mergeNodePreserve(dst, src *yaml.Node) { continue } mergeNodePreserve(dst.Content[i], src.Content[i]) + if dst.Content[i] != nil && src.Content[i] != nil && + dst.Content[i].Kind == yaml.MappingNode && src.Content[i].Kind == yaml.MappingNode { + pruneMissingMapKeys(dst.Content[i], src.Content[i]) + } } // Append any extra items from src for i := len(dst.Content); i < len(src.Content); i++ { @@ -1075,6 +1080,73 @@ func removeLegacyOpenAICompatAPIKeys(root *yaml.Node) { } } +func pruneMappingToGeneratedKeys(dstRoot, srcRoot *yaml.Node, key string) { + if key == "" || dstRoot == nil || srcRoot == nil { + return + } + if dstRoot.Kind != yaml.MappingNode || srcRoot.Kind != yaml.MappingNode { + return + } + dstIdx := findMapKeyIndex(dstRoot, key) + if dstIdx < 0 || dstIdx+1 >= len(dstRoot.Content) { + return + } + srcIdx := findMapKeyIndex(srcRoot, key) + if srcIdx < 0 { + removeMapKey(dstRoot, key) + return + } + if srcIdx+1 >= len(srcRoot.Content) { + return + } + srcVal := srcRoot.Content[srcIdx+1] + dstVal := dstRoot.Content[dstIdx+1] + if srcVal == nil { + dstRoot.Content[dstIdx+1] = nil + return + } + if srcVal.Kind != yaml.MappingNode { + dstRoot.Content[dstIdx+1] = deepCopyNode(srcVal) + return + } + if dstVal == nil || dstVal.Kind != yaml.MappingNode { + dstRoot.Content[dstIdx+1] = deepCopyNode(srcVal) + return + } + pruneMissingMapKeys(dstVal, srcVal) +} + +func pruneMissingMapKeys(dstMap, srcMap *yaml.Node) { + if dstMap == nil || srcMap == nil || dstMap.Kind != yaml.MappingNode || srcMap.Kind != yaml.MappingNode { + return + } + keep := make(map[string]struct{}, len(srcMap.Content)/2) + for i := 0; i+1 < len(srcMap.Content); i += 2 { + keyNode := srcMap.Content[i] + if keyNode == nil { + continue + } + key := strings.TrimSpace(keyNode.Value) + if key == "" { + continue + } + keep[key] = struct{}{} + } + for i := 0; i+1 < len(dstMap.Content); { + keyNode := dstMap.Content[i] + if keyNode == nil { + i += 2 + continue + } + key := strings.TrimSpace(keyNode.Value) + if _, ok := keep[key]; !ok { + dstMap.Content = append(dstMap.Content[:i], dstMap.Content[i+2:]...) + continue + } + i += 2 + } +} + // normalizeCollectionNodeStyles forces YAML collections to use block notation, keeping // lists and maps readable. Empty sequences retain flow style ([]) so empty list markers // remain compact. From 3ebbab0a9acc3b34decafb4c9793e94a73678d2f Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Tue, 2 Dec 2025 22:17:54 +0800 Subject: [PATCH 2/2] Revert watcher.go in "fix: enable hot reload for amp-model-mappings config" --- internal/watcher/watcher.go | 28 +++++----------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 02abb743..7e6c2631 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -162,14 +162,12 @@ func NewWatcher(configPath, authDir string, reloadCallback func(*config.Config)) // Start begins watching the configuration file and authentication directory func (w *Watcher) Start(ctx context.Context) error { - // Watch the config file's parent directory instead of the file itself. - // This handles editors that use atomic save (write to temp, then rename). - configDir := filepath.Dir(w.configPath) - if errAddConfig := w.watcher.Add(configDir); errAddConfig != nil { - log.Errorf("failed to watch config directory %s: %v", configDir, errAddConfig) + // Watch the config file + if errAddConfig := w.watcher.Add(w.configPath); errAddConfig != nil { + log.Errorf("failed to watch config file %s: %v", w.configPath, errAddConfig) return errAddConfig } - log.Debugf("watching config directory: %s (for file: %s)", configDir, filepath.Base(w.configPath)) + log.Debugf("watching config file: %s", w.configPath) // Watch the auth directory if errAddAuthDir := w.watcher.Add(w.authDir); errAddAuthDir != nil { @@ -714,23 +712,7 @@ func (w *Watcher) isKnownAuthFile(path string) bool { func (w *Watcher) handleEvent(event fsnotify.Event) { // Filter only relevant events: config file or auth-dir JSON files. configOps := fsnotify.Write | fsnotify.Create | fsnotify.Rename - // Check if this event is for our config file (handle both exact match and basename match for directory watching) - isConfigEvent := false - if event.Op&configOps != 0 { - // Exact path match - if event.Name == w.configPath { - isConfigEvent = true - } else { - // Check if basename matches and it's in the config directory (for atomic save detection) - configDir := filepath.Dir(w.configPath) - configBase := filepath.Base(w.configPath) - eventDir := filepath.Dir(event.Name) - eventBase := filepath.Base(event.Name) - if eventDir == configDir && eventBase == configBase { - isConfigEvent = true - } - } - } + isConfigEvent := event.Name == w.configPath && event.Op&configOps != 0 authOps := fsnotify.Create | fsnotify.Write | fsnotify.Remove | fsnotify.Rename isAuthJSON := strings.HasPrefix(event.Name, w.authDir) && strings.HasSuffix(event.Name, ".json") && event.Op&authOps != 0 if !isConfigEvent && !isAuthJSON {