jim800121chen 3f0175f1a9 feat(local-agent): Phase 0.5 visionA Agent — Wails 桌面 + tunnel client + 配對 UI
從 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>
2026-05-01 11:22:01 +08:00

278 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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-safeRWMutexManager 讀、Settings binding 寫。
// - 不存 session token那在 TokenStore
// - Validation 放在 Save 之前:非法值不落檔,保留舊值。
// - 檔案不存在時 Load 回預設值(不是 error
// - YAML 壞掉時 Load 回預設值 + 寫 WARN log不崩潰。
// - Schema 用 YAML struct tagJSON tag 是為了「Agent 自己序列化回前端」,
// 避免兩個結構對應造成前後端對不上。
//
// 非目標:
// - 不支援 schema migrationPhase 1 需要時再加 version 判讀)
// - 不支援 live reloadUI 改完由 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 可為 nilnil 時用 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 → realcrash 中途不會留半寫檔。
func (s *Store) Save(cfg Config) error {
// Validate 不需要 lockConfig 是 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合法
}