store.go

  1package config
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"log/slog"
  8	"os"
  9	"path/filepath"
 10	"slices"
 11
 12	"charm.land/catwalk/pkg/catwalk"
 13	hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
 14	"github.com/charmbracelet/crush/internal/env"
 15	"github.com/charmbracelet/crush/internal/oauth"
 16	"github.com/charmbracelet/crush/internal/oauth/copilot"
 17	"github.com/charmbracelet/crush/internal/oauth/hyper"
 18	"github.com/tidwall/gjson"
 19	"github.com/tidwall/sjson"
 20)
 21
 22// fileSnapshot captures metadata about a config file at a point in time.
 23type fileSnapshot struct {
 24	Path    string
 25	Exists  bool
 26	Size    int64
 27	ModTime int64 // UnixNano
 28}
 29
 30// RuntimeOverrides holds per-session settings that are never persisted to
 31// disk. They are applied on top of the loaded Config and survive only for
 32// the lifetime of the process (or workspace).
 33type RuntimeOverrides struct {
 34	SkipPermissionRequests bool
 35}
 36
 37// ConfigStore is the single entry point for all config access. It owns the
 38// pure-data Config, runtime state (working directory, resolver, known
 39// providers), and persistence to both global and workspace config files.
 40type ConfigStore struct {
 41	config             *Config
 42	workingDir         string
 43	resolver           VariableResolver
 44	globalDataPath     string   // ~/.local/share/crush/crush.json
 45	workspacePath      string   // .crush/crush.json
 46	loadedPaths        []string // config files that were successfully loaded
 47	knownProviders     []catwalk.Provider
 48	overrides          RuntimeOverrides
 49	trackedConfigPaths []string                // unique, normalized config file paths
 50	snapshots          map[string]fileSnapshot // path -> snapshot at last capture
 51	autoReloadDisabled bool                    // set during load/reload to prevent re-entrancy
 52	reloadInProgress   bool                    // set during reload to avoid disk writes mid-reload
 53}
 54
 55// Config returns the pure-data config struct (read-only after load).
 56func (s *ConfigStore) Config() *Config {
 57	return s.config
 58}
 59
 60// WorkingDir returns the current working directory.
 61func (s *ConfigStore) WorkingDir() string {
 62	return s.workingDir
 63}
 64
 65// Resolver returns the variable resolver.
 66func (s *ConfigStore) Resolver() VariableResolver {
 67	return s.resolver
 68}
 69
 70// Resolve resolves a variable reference using the configured resolver.
 71func (s *ConfigStore) Resolve(key string) (string, error) {
 72	if s.resolver == nil {
 73		return "", fmt.Errorf("no variable resolver configured")
 74	}
 75	return s.resolver.ResolveValue(key)
 76}
 77
 78// KnownProviders returns the list of known providers.
 79func (s *ConfigStore) KnownProviders() []catwalk.Provider {
 80	return s.knownProviders
 81}
 82
 83// SetupAgents configures the coder and task agents on the config.
 84func (s *ConfigStore) SetupAgents() {
 85	s.config.SetupAgents()
 86}
 87
 88// Overrides returns the runtime overrides for this store.
 89func (s *ConfigStore) Overrides() *RuntimeOverrides {
 90	return &s.overrides
 91}
 92
 93// LoadedPaths returns the config file paths that were successfully loaded.
 94func (s *ConfigStore) LoadedPaths() []string {
 95	return slices.Clone(s.loadedPaths)
 96}
 97
 98// configPath returns the file path for the given scope.
 99func (s *ConfigStore) configPath(scope Scope) (string, error) {
100	switch scope {
101	case ScopeWorkspace:
102		if s.workspacePath == "" {
103			return "", ErrNoWorkspaceConfig
104		}
105		return s.workspacePath, nil
106	default:
107		return s.globalDataPath, nil
108	}
109}
110
111// HasConfigField checks whether a key exists in the config file for the given
112// scope.
113func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
114	path, err := s.configPath(scope)
115	if err != nil {
116		return false
117	}
118	data, err := os.ReadFile(path)
119	if err != nil {
120		return false
121	}
122	return gjson.Get(string(data), key).Exists()
123}
124
125// SetConfigField sets a key/value pair in the config file for the given scope.
126// After a successful write, it automatically reloads config to keep in-memory
127// state fresh.
128func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
129	return s.SetConfigFields(scope, map[string]any{key: value})
130}
131
132// SetConfigFields sets multiple key/value pairs in the config file for the given
133// scope in a single write. After a successful write, it automatically reloads
134// config to keep in-memory state fresh. This is preferred over multiple
135// SetConfigField calls when writing several fields atomically to avoid
136// intermediate reloads with partial state.
137func (s *ConfigStore) SetConfigFields(scope Scope, kv map[string]any) error {
138	path, err := s.configPath(scope)
139	if err != nil {
140		return fmt.Errorf("%v: %w", kv, err)
141	}
142	data, err := os.ReadFile(path)
143	if err != nil {
144		if os.IsNotExist(err) {
145			data = []byte("{}")
146		} else {
147			return fmt.Errorf("failed to read config file: %w", err)
148		}
149	}
150
151	newValue := string(data)
152	for key, value := range kv {
153		newValue, err = sjson.Set(newValue, key, value)
154		if err != nil {
155			return fmt.Errorf("failed to set config field %s: %w", key, err)
156		}
157	}
158	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
159		return fmt.Errorf("failed to create config directory %q: %w", path, err)
160	}
161	if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
162		return fmt.Errorf("failed to write config file: %w", err)
163	}
164
165	// Auto-reload to keep in-memory state fresh after config edits.
166	// We use context.Background() since this is an internal operation that
167	// shouldn't be cancelled by user context.
168	if err := s.autoReload(context.Background()); err != nil {
169		// Log warning but don't fail the write - disk is already updated.
170		slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
171	}
172
173	return nil
174}
175
176// RemoveConfigField removes a key from the config file for the given scope.
177// After a successful write, it automatically reloads config to keep in-memory
178// state fresh.
179func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
180	path, err := s.configPath(scope)
181	if err != nil {
182		return fmt.Errorf("%s: %w", key, err)
183	}
184	data, err := os.ReadFile(path)
185	if err != nil {
186		return fmt.Errorf("failed to read config file: %w", err)
187	}
188
189	newValue, err := sjson.Delete(string(data), key)
190	if err != nil {
191		return fmt.Errorf("failed to delete config field %s: %w", key, err)
192	}
193	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
194		return fmt.Errorf("failed to create config directory %q: %w", path, err)
195	}
196	if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
197		return fmt.Errorf("failed to write config file: %w", err)
198	}
199
200	// Auto-reload to keep in-memory state fresh after config edits.
201	if err := s.autoReload(context.Background()); err != nil {
202		slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
203	}
204
205	return nil
206}
207
208// UpdatePreferredModel updates the preferred model for the given type and
209// persists it to the config file at the given scope.
210func (s *ConfigStore) UpdatePreferredModel(scope Scope, modelType SelectedModelType, model SelectedModel) error {
211	s.config.Models[modelType] = model
212	if err := s.SetConfigField(scope, fmt.Sprintf("models.%s", modelType), model); err != nil {
213		return fmt.Errorf("failed to update preferred model: %w", err)
214	}
215	if err := s.recordRecentModel(scope, modelType, model); err != nil {
216		return err
217	}
218	return nil
219}
220
221// SetCompactMode sets the compact mode setting and persists it.
222func (s *ConfigStore) SetCompactMode(scope Scope, enabled bool) error {
223	if s.config.Options == nil {
224		s.config.Options = &Options{}
225	}
226	s.config.Options.TUI.CompactMode = enabled
227	return s.SetConfigField(scope, "options.tui.compact_mode", enabled)
228}
229
230// SetTransparentBackground sets the transparent background setting and persists it.
231func (s *ConfigStore) SetTransparentBackground(scope Scope, enabled bool) error {
232	if s.config.Options == nil {
233		s.config.Options = &Options{}
234	}
235	s.config.Options.TUI.Transparent = &enabled
236	return s.SetConfigField(scope, "options.tui.transparent", enabled)
237}
238
239// SetProviderAPIKey sets the API key for a provider and persists it.
240func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey any) error {
241	var providerConfig ProviderConfig
242	var exists bool
243	var setKeyOrToken func()
244
245	switch v := apiKey.(type) {
246	case string:
247		if err := s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), v); err != nil {
248			return fmt.Errorf("failed to save api key to config file: %w", err)
249		}
250		setKeyOrToken = func() { providerConfig.APIKey = v }
251	case *oauth.Token:
252		if err := s.SetConfigFields(scope, map[string]any{
253			fmt.Sprintf("providers.%s.api_key", providerID): v.AccessToken,
254			fmt.Sprintf("providers.%s.oauth", providerID):   v,
255		}); err != nil {
256			return err
257		}
258		setKeyOrToken = func() {
259			providerConfig.APIKey = v.AccessToken
260			providerConfig.OAuthToken = v
261			switch providerID {
262			case string(catwalk.InferenceProviderCopilot):
263				providerConfig.SetupGitHubCopilot()
264			}
265		}
266	}
267
268	providerConfig, exists = s.config.Providers.Get(providerID)
269	if exists {
270		setKeyOrToken()
271		s.config.Providers.Set(providerID, providerConfig)
272		return nil
273	}
274
275	var foundProvider *catwalk.Provider
276	for _, p := range s.knownProviders {
277		if string(p.ID) == providerID {
278			foundProvider = &p
279			break
280		}
281	}
282
283	if foundProvider != nil {
284		providerConfig = ProviderConfig{
285			ID:           providerID,
286			Name:         foundProvider.Name,
287			BaseURL:      foundProvider.APIEndpoint,
288			Type:         foundProvider.Type,
289			Disable:      false,
290			ExtraHeaders: make(map[string]string),
291			ExtraParams:  make(map[string]string),
292			Models:       foundProvider.Models,
293		}
294		setKeyOrToken()
295	} else {
296		return fmt.Errorf("provider with ID %s not found in known providers", providerID)
297	}
298	s.config.Providers.Set(providerID, providerConfig)
299	return nil
300}
301
302// RefreshOAuthToken refreshes the OAuth token for the given provider.
303// Before making an external refresh request, it checks the config file on
304// disk to see if another Crush session has already refreshed the token. If
305// a newer token is found, it is used instead of refreshing.
306func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
307	providerConfig, exists := s.config.Providers.Get(providerID)
308	if !exists {
309		return fmt.Errorf("provider %s not found", providerID)
310	}
311
312	if providerConfig.OAuthToken == nil {
313		return fmt.Errorf("provider %s does not have an OAuth token", providerID)
314	}
315
316	// Check if another session refreshed the token recently by reading
317	// the current token from the config file on disk.
318	newToken, err := s.loadTokenFromDisk(scope, providerID)
319	if err != nil {
320		slog.Warn("Failed to read token from config file, proceeding with refresh", "provider", providerID, "error", err)
321	} else if newToken != nil && newToken.AccessToken != providerConfig.OAuthToken.AccessToken {
322		slog.Info("Using token refreshed by another session", "provider", providerID)
323		providerConfig.OAuthToken = newToken
324		providerConfig.APIKey = newToken.AccessToken
325		if providerID == string(catwalk.InferenceProviderCopilot) {
326			providerConfig.SetupGitHubCopilot()
327		}
328		s.config.Providers.Set(providerID, providerConfig)
329		return nil
330	}
331
332	var refreshedToken *oauth.Token
333	var refreshErr error
334	switch providerID {
335	case string(catwalk.InferenceProviderCopilot):
336		refreshedToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
337	case hyperp.Name:
338		refreshedToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken)
339	default:
340		return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
341	}
342	if refreshErr != nil {
343		return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
344	}
345
346	slog.Info("Successfully refreshed OAuth token", "provider", providerID)
347	providerConfig.OAuthToken = refreshedToken
348	providerConfig.APIKey = refreshedToken.AccessToken
349
350	switch providerID {
351	case string(catwalk.InferenceProviderCopilot):
352		providerConfig.SetupGitHubCopilot()
353	}
354
355	s.config.Providers.Set(providerID, providerConfig)
356
357	if err := s.SetConfigFields(scope, map[string]any{
358		fmt.Sprintf("providers.%s.api_key", providerID): refreshedToken.AccessToken,
359		fmt.Sprintf("providers.%s.oauth", providerID):   refreshedToken,
360	}); err != nil {
361		return fmt.Errorf("failed to persist refreshed token: %w", err)
362	}
363
364	return nil
365}
366
367// loadTokenFromDisk reads the OAuth token for the given provider from the
368// config file on disk. Returns nil if the token is not found or matches the
369// current in-memory token.
370func (s *ConfigStore) loadTokenFromDisk(scope Scope, providerID string) (*oauth.Token, error) {
371	path, err := s.configPath(scope)
372	if err != nil {
373		return nil, err
374	}
375
376	data, err := os.ReadFile(path)
377	if err != nil {
378		if os.IsNotExist(err) {
379			return nil, nil
380		}
381		return nil, err
382	}
383
384	oauthKey := fmt.Sprintf("providers.%s.oauth", providerID)
385	oauthResult := gjson.Get(string(data), oauthKey)
386	if !oauthResult.Exists() {
387		return nil, nil
388	}
389
390	var token oauth.Token
391	if err := json.Unmarshal([]byte(oauthResult.Raw), &token); err != nil {
392		return nil, err
393	}
394
395	if token.AccessToken == "" {
396		return nil, nil
397	}
398
399	return &token, nil
400}
401
402// recordRecentModel records a model in the recent models list.
403func (s *ConfigStore) recordRecentModel(scope Scope, modelType SelectedModelType, model SelectedModel) error {
404	if model.Provider == "" || model.Model == "" {
405		return nil
406	}
407
408	if s.config.RecentModels == nil {
409		s.config.RecentModels = make(map[SelectedModelType][]SelectedModel)
410	}
411
412	eq := func(a, b SelectedModel) bool {
413		return a.Provider == b.Provider && a.Model == b.Model
414	}
415
416	entry := SelectedModel{
417		Provider: model.Provider,
418		Model:    model.Model,
419	}
420
421	current := s.config.RecentModels[modelType]
422	withoutCurrent := slices.DeleteFunc(slices.Clone(current), func(existing SelectedModel) bool {
423		return eq(existing, entry)
424	})
425
426	updated := append([]SelectedModel{entry}, withoutCurrent...)
427	if len(updated) > maxRecentModelsPerType {
428		updated = updated[:maxRecentModelsPerType]
429	}
430
431	if slices.EqualFunc(current, updated, eq) {
432		return nil
433	}
434
435	s.config.RecentModels[modelType] = updated
436
437	if err := s.SetConfigField(scope, fmt.Sprintf("recent_models.%s", modelType), updated); err != nil {
438		return fmt.Errorf("failed to persist recent models: %w", err)
439	}
440
441	return nil
442}
443
444// NewTestStore creates a ConfigStore for testing purposes.
445func NewTestStore(cfg *Config, loadedPaths ...string) *ConfigStore {
446	return &ConfigStore{
447		config:      cfg,
448		loadedPaths: loadedPaths,
449	}
450}
451
452// ImportCopilot attempts to import a GitHub Copilot token from disk.
453func (s *ConfigStore) ImportCopilot() (*oauth.Token, bool) {
454	if s.HasConfigField(ScopeGlobal, "providers.copilot.api_key") || s.HasConfigField(ScopeGlobal, "providers.copilot.oauth") {
455		return nil, false
456	}
457
458	diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
459	if !hasDiskToken {
460		return nil, false
461	}
462
463	slog.Info("Found existing GitHub Copilot token on disk. Authenticating...")
464	token, err := copilot.RefreshToken(context.TODO(), diskToken)
465	if err != nil {
466		slog.Error("Unable to import GitHub Copilot token", "error", err)
467		return nil, false
468	}
469
470	if err := s.SetProviderAPIKey(ScopeGlobal, string(catwalk.InferenceProviderCopilot), token); err != nil {
471		return token, false
472	}
473
474	if err := s.SetConfigFields(ScopeGlobal, map[string]any{
475		"providers.copilot.api_key": token.AccessToken,
476		"providers.copilot.oauth":   token,
477	}); err != nil {
478		slog.Error("Unable to save GitHub Copilot token to disk", "error", err)
479	}
480
481	slog.Info("GitHub Copilot successfully imported")
482	return token, true
483}
484
485// StalenessResult contains the result of a staleness check.
486type StalenessResult struct {
487	Dirty   bool
488	Changed []string
489	Missing []string
490	Errors  map[string]error // stat errors by path
491}
492
493// ConfigStaleness checks whether any tracked config files have changed on disk
494// since the last snapshot. Returns dirty=true if any files changed or went
495// missing, along with sorted lists of affected paths. Stat errors are
496// captured in Errors map but still treated as non-existence for dirty detection.
497func (s *ConfigStore) ConfigStaleness() StalenessResult {
498	var result StalenessResult
499	result.Errors = make(map[string]error)
500
501	for _, path := range s.trackedConfigPaths {
502		snapshot, hadSnapshot := s.snapshots[path]
503
504		info, err := os.Stat(path)
505		exists := err == nil && !info.IsDir()
506
507		if err != nil && !os.IsNotExist(err) {
508			// Capture permission/IO errors separately from non-existence
509			result.Errors[path] = err
510			result.Dirty = true
511		}
512
513		if !exists {
514			if hadSnapshot && snapshot.Exists {
515				// File existed before but now missing
516				result.Missing = append(result.Missing, path)
517				result.Dirty = true
518			}
519			continue
520		}
521
522		// File exists now
523		if !hadSnapshot || !snapshot.Exists {
524			// File didn't exist before but does now
525			result.Changed = append(result.Changed, path)
526			result.Dirty = true
527			continue
528		}
529
530		// Check for content or metadata changes
531		if snapshot.Size != info.Size() || snapshot.ModTime != info.ModTime().UnixNano() {
532			result.Changed = append(result.Changed, path)
533			result.Dirty = true
534		}
535	}
536
537	// Sort for deterministic output
538	slices.Sort(result.Changed)
539	slices.Sort(result.Missing)
540
541	return result
542}
543
544// RefreshStalenessSnapshot captures fresh snapshots of all tracked config files.
545// Call this after reloading config to clear dirty state.
546func (s *ConfigStore) RefreshStalenessSnapshot() error {
547	if s.snapshots == nil {
548		s.snapshots = make(map[string]fileSnapshot)
549	}
550
551	for _, path := range s.trackedConfigPaths {
552		info, err := os.Stat(path)
553		exists := err == nil && !info.IsDir()
554
555		snapshot := fileSnapshot{
556			Path:   path,
557			Exists: exists,
558		}
559
560		if exists {
561			snapshot.Size = info.Size()
562			snapshot.ModTime = info.ModTime().UnixNano()
563		}
564
565		s.snapshots[path] = snapshot
566	}
567
568	return nil
569}
570
571// CaptureStalenessSnapshot captures snapshots for the given paths, building the
572// tracked config paths list. Paths are deduplicated and normalized.
573func (s *ConfigStore) CaptureStalenessSnapshot(paths []string) {
574	// Build unique set of normalized paths
575	seen := make(map[string]struct{})
576	for _, p := range paths {
577		if p == "" {
578			continue
579		}
580		// Normalize path
581		abs, err := filepath.Abs(p)
582		if err != nil {
583			abs = p
584		}
585		seen[abs] = struct{}{}
586	}
587
588	// Also track workspace and global config paths if set
589	if s.workspacePath != "" {
590		abs, err := filepath.Abs(s.workspacePath)
591		if err == nil {
592			seen[abs] = struct{}{}
593		}
594	}
595	if s.globalDataPath != "" {
596		abs, err := filepath.Abs(s.globalDataPath)
597		if err == nil {
598			seen[abs] = struct{}{}
599		}
600	}
601
602	// Build sorted list for deterministic ordering
603	s.trackedConfigPaths = make([]string, 0, len(seen))
604	for p := range seen {
605		s.trackedConfigPaths = append(s.trackedConfigPaths, p)
606	}
607	slices.Sort(s.trackedConfigPaths)
608
609	// Capture initial snapshots
610	s.RefreshStalenessSnapshot()
611}
612
613// captureStalenessSnapshot is an alias for CaptureStalenessSnapshot for internal use.
614func (s *ConfigStore) captureStalenessSnapshot(paths []string) {
615	s.CaptureStalenessSnapshot(paths)
616}
617
618// ReloadFromDisk re-runs the config load/merge flow and updates the in-memory
619// config atomically. It rebuilds the staleness snapshot after successful reload.
620// On failure, the store state is rolled back to its previous state.
621func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
622	if s.workingDir == "" {
623		return fmt.Errorf("cannot reload: working directory not set")
624	}
625
626	// Disable auto-reload during reload to prevent nested/re-entrant calls.
627	s.autoReloadDisabled = true
628	s.reloadInProgress = true
629	defer func() {
630		s.autoReloadDisabled = false
631		s.reloadInProgress = false
632	}()
633
634	configPaths := lookupConfigs(s.workingDir)
635	cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
636	if err != nil {
637		return fmt.Errorf("failed to reload config: %w", err)
638	}
639
640	// Apply defaults (using existing data directory if set)
641	var dataDir string
642	if s.config != nil && s.config.Options != nil {
643		dataDir = s.config.Options.DataDirectory
644	}
645	cfg.setDefaults(s.workingDir, dataDir)
646
647	// Merge workspace config if present
648	workspacePath := filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName))
649	if wsData, err := os.ReadFile(workspacePath); err == nil && len(wsData) > 0 {
650		merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
651		if mergeErr == nil {
652			dataDir := cfg.Options.DataDirectory
653			*cfg = *merged
654			cfg.setDefaults(s.workingDir, dataDir)
655			loadedPaths = append(loadedPaths, workspacePath)
656		}
657	}
658
659	// Validate hooks after all config merging is complete so matcher
660	// regexes are recompiled on the reloaded config (mirrors Load).
661	if err := cfg.ValidateHooks(); err != nil {
662		return fmt.Errorf("invalid hook configuration on reload: %w", err)
663	}
664
665	// Preserve runtime overrides
666	overrides := s.overrides
667
668	// Reconfigure providers
669	env := env.New()
670	resolver := NewShellVariableResolver(env)
671	providers, err := Providers(cfg)
672	if err != nil {
673		return fmt.Errorf("failed to load providers during reload: %w", err)
674	}
675
676	if err := cfg.configureProviders(s, env, resolver, providers); err != nil {
677		return fmt.Errorf("failed to configure providers during reload: %w", err)
678	}
679
680	// Save current state for potential rollback
681	oldConfig := s.config
682	oldLoadedPaths := s.loadedPaths
683	oldResolver := s.resolver
684	oldKnownProviders := s.knownProviders
685	oldOverrides := s.overrides
686	oldWorkspacePath := s.workspacePath
687
688	// Update store state BEFORE running model/agent setup (so they see new config)
689	s.config = cfg
690	s.loadedPaths = loadedPaths
691	s.resolver = resolver
692	s.knownProviders = providers
693	s.overrides = overrides
694	s.workspacePath = workspacePath
695
696	// Mirror startup flow: setup models and agents against NEW config
697	var setupErr error
698	if !cfg.IsConfigured() {
699		slog.Warn("No providers configured after reload")
700	} else {
701		if err := configureSelectedModels(s, providers, false); err != nil {
702			setupErr = fmt.Errorf("failed to configure selected models during reload: %w", err)
703		} else {
704			s.SetupAgents()
705		}
706	}
707
708	// Rollback on setup failure
709	if setupErr != nil {
710		s.config = oldConfig
711		s.loadedPaths = oldLoadedPaths
712		s.resolver = oldResolver
713		s.knownProviders = oldKnownProviders
714		s.overrides = oldOverrides
715		s.workspacePath = oldWorkspacePath
716		return setupErr
717	}
718
719	// Rebuild staleness tracking
720	s.captureStalenessSnapshot(loadedPaths)
721
722	return nil
723}
724
725// autoReload conditionally reloads config from disk after writes.
726// It returns nil (no error) for expected skip cases: when auto-reload is
727// disabled during load/reload flows, or when working directory is not set
728// (e.g., during testing). Only actual reload failures return an error.
729func (s *ConfigStore) autoReload(ctx context.Context) error {
730	if s.autoReloadDisabled {
731		return nil // Expected skip: already in load/reload flow
732	}
733	if s.workingDir == "" {
734		return nil // Expected skip: working directory not set
735	}
736	return s.ReloadFromDisk(ctx)
737}