package main // agent_bindings.go — visionA Agent 專屬 Wails bindings // // AB2+AB3 建立 stub;AB5 補上 GetConnectionStatus / Pair / Unpair 的真正實作 // (委派 tunnelManager);AB7-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 ms,0 代表尚未連線 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 millis(frontend 接 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 → TODO(AB11+:寫入 OS autostart entry;雛形只存 config) // - LogLevel → TODO(Phase 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. Unpair(Manager.Unpair → 清 TokenStore + Stop tunnel + 狀態回 notPaired) // 2. 把 configStore Save 成 Default(Validate 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, }, } }