從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑: tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。 Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local), 雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。 Backend / Wails Go(AB1-AB13): - internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped) + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event - internal/auth:encrypted file token store(AES-GCM + scrypt + machineID fallback salt + 13 tests) - internal/config:YAML validation + atomic write + 11 tests - internal/log:ring buffer + ExportLog 升級 zip - visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests - 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage) - end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護 → tunnel drop failover) Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎): - AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab) - ConnectionStatusBadge 5 種狀態 - TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁 - 設定頁 4 區塊(含重新配對 AlertDialog) - agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests Phase 0.7 review-driven fix(Round 2): - A1 Session fixation 防護(RotateSessionID) - A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log - A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態) - A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test - F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL / F4 Settings draft 持久 + 未儲存 badge 驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 / agent frontend pnpm test 119 tests 全綠 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
278 lines
7.7 KiB
Go
278 lines
7.7 KiB
Go
// 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
|
||
//
|
||
// 檔案位置:`<dataDir>/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 並嘗試從 `<dataDir>/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,合法)
|
||
}
|