// agentconfig — visionA Agent 使用者層級設定(AB9 範圍)。 // // TDD §10 指定的 YAML 檔: // // version: 1 // relay: // url: wss://relay.visionA.cloud // reconnect: auto # auto | manual // behavior: // autostart: false // log: // level: info # debug | info | warn | error // // 檔案位置:`/config.yaml`(跟 token.bin 同目錄)。 // // 設計要點: // // - Thread-safe:RWMutex;Manager 讀、Settings binding 寫。 // - 不存 session token(那在 TokenStore)。 // - Validation 放在 Save 之前:非法值不落檔,保留舊值。 // - 檔案不存在時 Load 回預設值(不是 error)。 // - YAML 壞掉時 Load 回預設值 + 寫 WARN log;不崩潰。 // - Schema 用 YAML struct tag;JSON tag 是為了「Agent 自己序列化回前端」, // 避免兩個結構對應造成前後端對不上。 // // 非目標: // - 不支援 schema migration(Phase 1 需要時再加 version 判讀) // - 不支援 live reload(UI 改完由 SaveSettings binding 直接寫 + 觸發 Manager reload) package agentconfig import ( "errors" "fmt" "log" "os" "path/filepath" "strings" "sync" "gopkg.in/yaml.v3" ) // 預設值(對齊 TDD §10 + design spec §6.2)。 const ( DefaultRelayURL = "wss://relay.visionA.cloud" DefaultReconnectMode = ReconnectAuto DefaultAutostart = false DefaultLogLevel = LogLevelInfo currentSchemaVersion = 1 ) // ReconnectMode 對齊 design spec §6.2.2 RadioGroup 選項。 type ReconnectMode string const ( ReconnectAuto ReconnectMode = "auto" ReconnectManual ReconnectMode = "manual" ) // LogLevel 對齊 design spec §6.2.3 Select 選項。 type LogLevel string const ( LogLevelDebug LogLevel = "debug" LogLevelInfo LogLevel = "info" LogLevelWarn LogLevel = "warn" LogLevelError LogLevel = "error" ) // Config 是持久化結構(YAML)。 // // ⚠️ yaml tag 對齊 TDD §10 的 schema;不要隨便改欄位名(壞使用者既有檔)。 type Config struct { Version int `yaml:"version" json:"version"` Relay RelayConfig `yaml:"relay" json:"relay"` Behavior BehaviorConfig `yaml:"behavior" json:"behavior"` Log LogConfig `yaml:"log" json:"log"` } type RelayConfig struct { URL string `yaml:"url" json:"url"` Reconnect ReconnectMode `yaml:"reconnect" json:"reconnect"` } type BehaviorConfig struct { Autostart bool `yaml:"autostart" json:"autostart"` } type LogConfig struct { Level LogLevel `yaml:"level" json:"level"` } // Default 回傳 TDD §10 規定的預設 Config。 func Default() Config { return Config{ Version: currentSchemaVersion, Relay: RelayConfig{ URL: DefaultRelayURL, Reconnect: DefaultReconnectMode, }, Behavior: BehaviorConfig{ Autostart: DefaultAutostart, }, Log: LogConfig{ Level: DefaultLogLevel, }, } } // Validate 檢查欄位值合法性。回 error 時呼叫端應拒絕這個 Config、保留舊值。 // // 規則: // - Relay.URL 必須是 ws:// 或 wss:// // - Relay.Reconnect 只能 "auto" 或 "manual" // - Log.Level 只能 debug / info / warn / error func (c *Config) Validate() error { var errs []string if !strings.HasPrefix(c.Relay.URL, "ws://") && !strings.HasPrefix(c.Relay.URL, "wss://") { errs = append(errs, fmt.Sprintf("relay.url must start with ws:// or wss:// (got %q)", c.Relay.URL)) } switch c.Relay.Reconnect { case ReconnectAuto, ReconnectManual: default: errs = append(errs, fmt.Sprintf("relay.reconnect must be \"auto\" or \"manual\" (got %q)", c.Relay.Reconnect)) } switch c.Log.Level { case LogLevelDebug, LogLevelInfo, LogLevelWarn, LogLevelError: default: errs = append(errs, fmt.Sprintf("log.level must be one of debug/info/warn/error (got %q)", c.Log.Level)) } if len(errs) > 0 { return errors.New("agentconfig invalid: " + strings.Join(errs, "; ")) } return nil } // Store 是 config.yaml 的持久化管理器。 // // 所有公開 API 都是 thread-safe。呼叫端保留 Store 長存(單例)。 type Store struct { mu sync.RWMutex path string logger Logger cfg Config } // Logger 與 internal/tunnel.Logger 對應但不共用,避免跨 package 循環依賴。 type Logger interface { Printf(format string, args ...interface{}) } // NewStore 建立 Store 並嘗試從 `/config.yaml` 載入。 // // 行為: // - 檔案不存在:用預設值並立即 Save(讓使用者在設定頁首次開啟就看到檔案) // - YAML 壞掉:用預設值 + 印 WARN log(不覆寫壞檔,方便 debug) // - Validation 失敗:用預設值 + 印 WARN log(不覆寫壞檔) // // logger 可為 nil,nil 時用 log.Default()。 func NewStore(dataDir string, logger Logger) (*Store, error) { if dataDir == "" { return nil, errors.New("agentconfig: NewStore requires non-empty dataDir") } if err := os.MkdirAll(dataDir, 0o700); err != nil { return nil, fmt.Errorf("agentconfig: cannot create dataDir %q: %w", dataDir, err) } s := &Store{ path: filepath.Join(dataDir, "config.yaml"), logger: logger, cfg: Default(), } data, err := os.ReadFile(s.path) if err != nil { if errors.Is(err, os.ErrNotExist) { // 首次啟動:把預設值寫一份,使用者 inspect 檔案時就能看到 schema if saveErr := s.saveLocked(s.cfg); saveErr != nil { s.logf("WARN: cannot write default config.yaml: %v", saveErr) } return s, nil } return nil, fmt.Errorf("agentconfig: read %q: %w", s.path, err) } var parsed Config if err := yaml.Unmarshal(data, &parsed); err != nil { s.logf("WARN: config.yaml parse failed (%v); using defaults", err) return s, nil } // 若欄位缺失(例如使用者只寫了一半),先 fill 預設再驗證 fillDefaults(&parsed) if err := parsed.Validate(); err != nil { s.logf("WARN: config.yaml invalid (%v); using defaults", err) return s, nil } s.cfg = parsed return s, nil } // Get 回傳當前 Config 的快照(複製,避免外部修改影響內部狀態)。 func (s *Store) Get() Config { s.mu.RLock() defer s.mu.RUnlock() return s.cfg } // Save 驗證 + 寫入新的 Config。非法值回 error 且不改變內部狀態。 // // 寫檔採 atomic rename(.tmp → real);crash 中途不會留半寫檔。 func (s *Store) Save(cfg Config) error { // Validate 不需要 lock(Config 是 value) if cfg.Version == 0 { cfg.Version = currentSchemaVersion } if err := cfg.Validate(); err != nil { return err } s.mu.Lock() defer s.mu.Unlock() if err := s.saveLocked(cfg); err != nil { return err } s.cfg = cfg return nil } // Path 回傳 config.yaml 的絕對路徑(debug / UI 顯示用)。 func (s *Store) Path() string { return s.path } // saveLocked 在持有 s.mu 時呼叫;序列化 + atomic write。 func (s *Store) saveLocked(cfg Config) error { data, err := yaml.Marshal(cfg) if err != nil { return fmt.Errorf("agentconfig: marshal: %w", err) } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, data, 0o600); err != nil { return fmt.Errorf("agentconfig: write tmp: %w", err) } if err := os.Rename(tmp, s.path); err != nil { _ = os.Remove(tmp) return fmt.Errorf("agentconfig: rename tmp → real: %w", err) } return nil } func (s *Store) logf(format string, args ...any) { if s.logger != nil { s.logger.Printf("[agentconfig] "+format, args...) return } log.Printf("[agentconfig] "+format, args...) } // fillDefaults 補齊缺欄位;使用者只寫半個檔案時仍能啟動。 func fillDefaults(c *Config) { d := Default() if c.Version == 0 { c.Version = d.Version } if c.Relay.URL == "" { c.Relay.URL = d.Relay.URL } if c.Relay.Reconnect == "" { c.Relay.Reconnect = d.Relay.Reconnect } if c.Log.Level == "" { c.Log.Level = d.Log.Level } // Autostart 是 bool,無「缺省」概念(zero value 就是 false,合法) }