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

357 lines
13 KiB
Go
Raw Permalink 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.

package main
// agent_bindings.go — visionA Agent 專屬 Wails bindings
//
// AB2+AB3 建立 stubAB5 補上 GetConnectionStatus / Pair / Unpair 的真正實作
// (委派 tunnelManagerAB7-AB10 補齊 Settings / Log / TestConnection / ResetAllSettings。
//
// 型別佈局:
// 本檔 `ConnectionState` / `ConnectionStatus` 是 Wails binding 對外合約
// frontend `src/types/agent.ts` 使用者看到的版本),與 tunnel package 內部
// 的 `tunnel.ConnectionState` / `tunnel.ConnectionStatus` 互相對應,由
// `snapshotToDTO` 做轉換。兩層分開:
// - tunnel package 內部可隨時重構;
// - binding 對外契約必須穩定frontend 依賴的型別只在這個檔案動)。
//
// Design spec 對應章節:`.autoflow/03-design/visiona-agent-spec.md` §4 / §5 / §6。
// TDD 對應章節:`.autoflow/04-architecture/visiona-agent-tdd.md` §6.1 / §6.4 / §10。
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"visiona-agent/internal/agentconfig"
"visiona-agent/internal/tunnel"
"github.com/gorilla/websocket"
)
// ErrNotImplemented 表示該 binding 對應的功能尚未在 Phase 0 雛形完成。
// 前端應捕捉此錯誤並以「尚未啟用」的提示呈現,而非崩潰。
var ErrNotImplemented = errors.New("visiona-agent: not implemented yet (pending AB4-AB10)")
// ErrAgentNotReady 表示 Agent 尚未完成啟動(例如 config store / tunnel manager
// 未建)。前端應提示使用者稍候。
var ErrAgentNotReady = errors.New("visiona-agent: agent not ready")
// -----------------------------------------------------------------------
// 狀態頁Design spec §4
// -----------------------------------------------------------------------
// ConnectionState 對應 TDD §6.4 connstate.State。
type ConnectionState string
const (
ConnStateNotPaired ConnectionState = "notPaired"
ConnStateConnecting ConnectionState = "connecting"
ConnStateOnline ConnectionState = "online"
ConnStateReconnecting ConnectionState = "reconnecting"
ConnStateOffline ConnectionState = "offline"
ConnStateError ConnectionState = "error"
)
// ConnectionStatus 是狀態頁主要資料來源(對齊 TDD §6.4 Snapshot
// 前端訂閱 Wails event "connection:status" 取得即時變化,啟動時一次性呼叫
// GetConnectionStatus() 取 initial snapshot。
type ConnectionStatus struct {
State ConnectionState `json:"state"`
Error string `json:"error,omitempty"`
AttemptNo int `json:"attemptNo,omitempty"`
RelayURL string `json:"relayUrl"`
Account string `json:"account,omitempty"`
ConnectedSinceMs int64 `json:"connectedSince,omitempty"` // Unix ms0 代表尚未連線
LastSeenMs int64 `json:"lastSeenAt,omitempty"` // Unix ms最後一次收到 yamux pong 的時間
SessionTokenPreview string `json:"sessionTokenPreview,omitempty"` // "vAs_a1b2c3d4 ··· e7f8"
}
// snapshotToDTO 把 tunnel package 的 snapshot 轉成 Wails binding 對外的 DTO。
// 負責兩件事:
// 1. 時間欄位 *time.Time → Unix millisfrontend 接 number不處理 Go time.Time 序列化)
// 2. 兜底 nil Manager回未配對狀態VISIONA_RELAY_URL 未設 → tunnelManager == nil
func snapshotToDTO(s tunnel.ConnectionStatus) ConnectionStatus {
dto := ConnectionStatus{
State: ConnectionState(s.State),
Error: s.LastError,
AttemptNo: s.AttemptNo,
RelayURL: s.RelayURL,
Account: s.Account,
SessionTokenPreview: s.SessionTokenPreview,
}
if s.ConnectedSince != nil {
dto.ConnectedSinceMs = s.ConnectedSince.UnixMilli()
}
if s.LastSeenAt != nil {
dto.LastSeenMs = s.LastSeenAt.UnixMilli()
}
return dto
}
// GetConnectionStatus 回傳當前 tunnel 連線狀態 snapshot。
//
// AB5已接入 tunnelManager。若 tunnelManager 為 nil啟動時未設 relay URL
// 回傳 notPaired 狀態但不回錯——狀態頁的「未配對」空狀態就是合法 UI 狀態。
func (a *App) GetConnectionStatus() (ConnectionStatus, error) {
if a.tunnelManager == nil {
return ConnectionStatus{State: ConnStateNotPaired}, nil
}
return snapshotToDTO(a.tunnelManager.Status()), nil
}
// -----------------------------------------------------------------------
// 配對頁Design spec §5
// -----------------------------------------------------------------------
// Pair 送出 Pairing Token 向雲端換 Session Token。
// 成功後 Agent 會自動啟動 tunnel client、並透過 "connection:status" event 推進狀態。
//
// AB5委派給 tunnelManager。前端應捕捉 tunnel.ErrInvalidTokenFormat /
// ErrTokenInvalid / ErrTokenExpired / ErrTokenUsed / ErrTokenRevoked 對應 spec §5.4 表格。
// 若 tunnelManager 為 nil啟動時 server port 未拿到),回 ErrNotImplemented 讓
// 前端顯示「Agent 還沒 ready」的提示。
func (a *App) Pair(pairingToken string) error {
if a.tunnelManager == nil {
return ErrAgentNotReady
}
ctx := a.ctx
if ctx == nil {
ctx = context.Background()
}
return a.tunnelManager.Pair(ctx, pairingToken)
}
// Unpair 清除本地 Session Token 並斷開 tunnel。
//
// AB5委派給 tunnelManager。
func (a *App) Unpair() error {
if a.tunnelManager == nil {
return nil // 未配對狀態Unpair 視為 no-op
}
return a.tunnelManager.Unpair()
}
// Reconnect 觸發手動重連。對應 spec §4.5「立即重試」按鈕。
// AB5 新增。
func (a *App) Reconnect() error {
if a.tunnelManager == nil {
return ErrAgentNotReady
}
ctx := a.ctx
if ctx == nil {
ctx = context.Background()
}
return a.tunnelManager.Reconnect(ctx)
}
// Disconnect 主動斷開 tunnel保留 token。對應 spec §4.2 (C) 「斷開連線」按鈕。
// AB5 新增。
func (a *App) Disconnect() error {
if a.tunnelManager == nil {
return nil
}
return a.tunnelManager.Stop()
}
// -----------------------------------------------------------------------
// 設定頁Design spec §6
// -----------------------------------------------------------------------
//
// ⚠️ 對齊前端 `src/types/agent.ts`
//
// export interface AgentSettings {
// relayUrl: string;
// autoStart: boolean;
// reconnectStrategy: "auto" | "manual";
// logLevel: "debug" | "info" | "warn" | "error";
// }
//
// 後端 struct 的 json tag 必須完全對應。改欄位名 = 壞前端。
// ReconnectStrategy 對齊 design spec §6.2.2 RadioGroup 選項frontend `ReconnectStrategy`)。
type ReconnectStrategy string
const (
ReconnectStrategyAuto ReconnectStrategy = "auto"
ReconnectStrategyManual ReconnectStrategy = "manual"
)
// AgentSettings 是設定頁的資料結構(對齊 TDD §10 agent config YAML + frontend types
//
// 對應 agentconfig.Config 的扁平版UI 不需要 version / relay.{url,reconnect}
// 的 nested 結構;前端習慣單層扁平 object。內部用 agentconfigToSettings /
// settingsToAgentConfig 互轉。
type AgentSettings struct {
RelayURL string `json:"relayUrl"`
AutoStart bool `json:"autoStart"`
ReconnectStrategy ReconnectStrategy `json:"reconnectStrategy"`
LogLevel string `json:"logLevel"` // "debug" | "info" | "warn" | "error"
}
// GetAgentSettings 讀取 Agent 層級設定。
//
// AB9 落地:從 `agentconfig.Store` 讀store 尚未建立時回預設值 + 不 error
// (讓前端能先渲染設定頁),不走 ErrAgentNotReady 的原因是:讀操作沒有副作用,
// 沒有 store 也能回預設值。
func (a *App) GetAgentSettings() (AgentSettings, error) {
if a.configStore == nil {
return agentConfigToSettings(agentconfig.Default()), nil
}
return agentConfigToSettings(a.configStore.Get()), nil
}
// SaveAgentSettings 驗證、寫 config.yaml、並套用到 Manager。
//
// 套用規則:
// - RelayURL / ReconnectStrategy → 呼叫 Manager.ApplyRelaySettings()
// 若 URL 改變且 Manager 在 running自動 Stop+Start
// - AutoStart → TODOAB11+:寫入 OS autostart entry雛形只存 config
// - LogLevel → TODOPhase 1套用到 logger雛形只存 config
//
// 驗證失敗不改內部狀態。
func (a *App) SaveAgentSettings(settings AgentSettings) error {
if a.configStore == nil {
return ErrAgentNotReady
}
cfg := settingsToAgentConfig(settings)
if err := a.configStore.Save(cfg); err != nil {
return err
}
// 套用到 Manager
if a.tunnelManager != nil {
reconnectMode := tunnel.ReconnectAuto
if settings.ReconnectStrategy == ReconnectStrategyManual {
reconnectMode = tunnel.ReconnectManual
}
if _, err := a.tunnelManager.ApplyRelaySettings(a.ctx, settings.RelayURL, reconnectMode); err != nil {
// apply 失敗不回 error設定已存檔只在 log 記一筆
a.appLog("SaveAgentSettings: ApplyRelaySettings failed: %v", err)
}
}
return nil
}
// TestResult 是 TestConnection binding 的回傳(對齊 frontend `TestRelayResult`)。
type TestResult struct {
OK bool `json:"ok"`
LatencyMs int64 `json:"latencyMs,omitempty"`
Reason string `json:"reason,omitempty"`
}
// TestConnection 驗證 Relay URL 的 reachability。
//
// 行為(對齊 Design spec §6.2.1
// - 嘗試 WebSocket upgrade不帶 token只測網路 + TLS 可達)
// - 建立後立即 close不發任何 frame
// - 失敗時回對應的本地化錯誤訊息reason
//
// Timeout 5 秒(使用者等得夠久就算失敗了)。
func (a *App) TestConnection(relayURL string) TestResult {
relayURL = strings.TrimSpace(relayURL)
if relayURL == "" {
return TestResult{OK: false, Reason: "URL is empty"}
}
if !strings.HasPrefix(relayURL, "ws://") && !strings.HasPrefix(relayURL, "wss://") {
return TestResult{OK: false, Reason: fmt.Sprintf("URL must start with ws:// or wss:// (got %q)", relayURL)}
}
if _, err := url.Parse(relayURL); err != nil {
return TestResult{OK: false, Reason: fmt.Sprintf("invalid URL: %v", err)}
}
dialer := websocket.Dialer{
HandshakeTimeout: 5 * time.Second,
}
headers := http.Header{}
start := time.Now()
conn, resp, err := dialer.Dial(relayURL, headers)
latency := time.Since(start).Milliseconds()
if err != nil {
reason := err.Error()
if resp != nil {
reason = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, reason)
}
return TestResult{OK: false, Reason: reason}
}
_ = conn.Close()
return TestResult{OK: true, LatencyMs: latency}
}
// ResetAllSettings 清除 config + session token等於 Unpair + 重置所有設定。
//
// 對應 Design spec §6.2.5「危險區域 → 重置所有設定」。
// 流程:
// 1. UnpairManager.Unpair → 清 TokenStore + Stop tunnel + 狀態回 notPaired
// 2. 把 configStore Save 成 DefaultValidate OK所以一定成功
// 3. 若後續 settings 變動,前端下次 GetAgentSettings 會拿到預設
func (a *App) ResetAllSettings() error {
if a.tunnelManager != nil {
if err := a.tunnelManager.Unpair(); err != nil {
return fmt.Errorf("unpair: %w", err)
}
}
if a.configStore != nil {
if err := a.configStore.Save(agentconfig.Default()); err != nil {
return fmt.Errorf("reset config: %w", err)
}
}
a.appLog("ResetAllSettings: config + session token cleared")
return nil
}
// -----------------------------------------------------------------------
// Log 相關Design spec §6.2 — 設定頁 Log 區塊)
// -----------------------------------------------------------------------
//
// GetRecentLogs / ExportLog 的實作在 server_control.go沿用 local-tool 的
// ring buffer 機制);本檔不重複。
//
// Design spec §6.2「最近 log」 ↔ a.GetRecentLogs(n) (server_control.go)
// Design spec §6.2「匯出 log」 ↔ a.ExportLog() (server_control.go)
// -----------------------------------------------------------------------
// 內部轉換agentconfig.Config ↔ AgentSettings
// -----------------------------------------------------------------------
// agentConfigToSettings 把 persistent config 轉 UI 用的扁平結構。
func agentConfigToSettings(c agentconfig.Config) AgentSettings {
strat := ReconnectStrategyAuto
if c.Relay.Reconnect == agentconfig.ReconnectManual {
strat = ReconnectStrategyManual
}
return AgentSettings{
RelayURL: c.Relay.URL,
AutoStart: c.Behavior.Autostart,
ReconnectStrategy: strat,
LogLevel: string(c.Log.Level),
}
}
// settingsToAgentConfig 把 UI 扁平結構轉 persistent config。
// Version 欄位由 Save() 自動補齊,這裡不設。
func settingsToAgentConfig(s AgentSettings) agentconfig.Config {
mode := agentconfig.ReconnectAuto
if s.ReconnectStrategy == ReconnectStrategyManual {
mode = agentconfig.ReconnectManual
}
level := agentconfig.LogLevel(s.LogLevel)
if level == "" {
level = agentconfig.DefaultLogLevel
}
return agentconfig.Config{
Relay: agentconfig.RelayConfig{
URL: s.RelayURL,
Reconnect: mode,
},
Behavior: agentconfig.BehaviorConfig{
Autostart: s.AutoStart,
},
Log: agentconfig.LogConfig{
Level: level,
},
}
}