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}