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