store.go

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