從 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>
1319 lines
42 KiB
Go
1319 lines
42 KiB
Go
package main
|
||
|
||
// server_control.go — M8-4:Server 生命週期狀態機 + bindings + logPump
|
||
//
|
||
// 本檔涵蓋 TDD:
|
||
// - v2/control-panel.md §4.2–§4.7:bindings、型別、state machine、logPump
|
||
// - v2/server-lifecycle.md §4–§6:stdout/stderr pipe 捕捉、ServerController 防呆、
|
||
// watchServer 改為 Error state
|
||
//
|
||
// 設計重點:
|
||
// 1. ServerController 用 txMu(transition lock)+ mu(field lock)雙鎖
|
||
// 2. Start / Stop / Restart 整段邏輯由 txMu 序列化,避免 race
|
||
// 3. 新的 startServerV2 用 StdoutPipe / StderrPipe 接 server 子行程,
|
||
// 配合 logPump goroutine 把每一行送進 LogBuffer + emit Wails event
|
||
// 4. Restart 強制保留舊 port(R5-F-2);cold start 允許 fallback
|
||
// 5. watchServer 崩潰時進 Error state,不 os.Exit;同時發 OS 通知(R5-D1)
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bufio"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||
)
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 型別定義
|
||
// -----------------------------------------------------------------------
|
||
|
||
// ServerState 是控制台顯示的高階狀態(TDD v2/control-panel.md §4.3)。
|
||
type ServerState string
|
||
|
||
const (
|
||
ServerStateIdle ServerState = "idle" // 初始,尚未啟動過
|
||
ServerStateStopped ServerState = "stopped" // 曾經 Running 過後又被停掉
|
||
ServerStateStarting ServerState = "starting" // 啟動中
|
||
ServerStateRunning ServerState = "running" // 正常運作
|
||
ServerStateStopping ServerState = "stopping" // 停止中(SIGTERM → grace → SIGKILL)
|
||
ServerStateError ServerState = "error" // 啟動失敗 / 健康檢查失敗
|
||
)
|
||
|
||
// ServerStatusV2 對應 control-panel.md §4.3。前端用這個結構更新 UI。
|
||
type ServerStatusV2 struct {
|
||
State ServerState `json:"state"`
|
||
Port int `json:"port,omitempty"`
|
||
URL string `json:"url,omitempty"`
|
||
PID int `json:"pid,omitempty"`
|
||
PythonBin string `json:"pythonBin,omitempty"`
|
||
PythonMode string `json:"pythonMode,omitempty"`
|
||
StartedAt int64 `json:"startedAt,omitempty"` // Unix ms
|
||
LastError string `json:"lastError,omitempty"`
|
||
}
|
||
|
||
// SystemInfo 給 Status card 顯示。
|
||
type SystemInfo struct {
|
||
AppVersion string `json:"appVersion"`
|
||
BuildTime string `json:"buildTime"`
|
||
DataDir string `json:"dataDir"`
|
||
LogsDir string `json:"logsDir"`
|
||
Platform string `json:"platform"`
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// ServerController — 狀態機 + Start/Stop/Restart
|
||
// -----------------------------------------------------------------------
|
||
|
||
// ServerController 持有 server 子行程的生命週期狀態。
|
||
// 它擁有 txMu(transition lock)+ mu(field lock)雙鎖:
|
||
// - txMu:Start / Stop / Restart 整段邏輯期間持有,保證互斥
|
||
// - mu:保護 state / proc / startedAt / lastError 的快速讀寫
|
||
type ServerController struct {
|
||
app *App
|
||
|
||
mu sync.Mutex
|
||
state ServerState
|
||
proc *ServerProcess
|
||
startedAt time.Time
|
||
lastError string
|
||
|
||
txMu sync.Mutex
|
||
}
|
||
|
||
// NewServerController 建立一個新的 controller。
|
||
func NewServerController(app *App) *ServerController {
|
||
return &ServerController{
|
||
app: app,
|
||
state: ServerStateIdle,
|
||
}
|
||
}
|
||
|
||
// State 回傳目前狀態(thread-safe snapshot)。
|
||
func (c *ServerController) State() ServerState {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return c.state
|
||
}
|
||
|
||
// setState 更新狀態並 emit Wails event。
|
||
// 注意:呼叫方不得持有 c.mu,本函式內部會取 c.mu。
|
||
func (c *ServerController) setState(s ServerState, errMsg string) {
|
||
c.mu.Lock()
|
||
c.state = s
|
||
c.lastError = errMsg
|
||
if s == ServerStateRunning {
|
||
c.startedAt = time.Now()
|
||
}
|
||
c.mu.Unlock()
|
||
|
||
// 取得最新 snapshot 後 emit
|
||
status := c.app.snapshotStatus()
|
||
if c.app.ctx != nil {
|
||
wailsRuntime.EventsEmit(c.app.ctx, "server:state-change", status)
|
||
}
|
||
}
|
||
|
||
// Start 啟動 server 子程序。冷啟動或手動從 Stopped / Error / Idle 啟動用。
|
||
// 允許 port fallback。
|
||
func (c *ServerController) Start() error {
|
||
return c.startInternal(0)
|
||
}
|
||
|
||
// StartWithPort 用指定的 preferred port 啟動。
|
||
// preferredPort == 0 表示走 cold start 邏輯(允許 fallback)。
|
||
// preferredPort != 0 表示必須使用該 port(Restart 路徑),port 被佔就失敗進 Error。
|
||
func (c *ServerController) StartWithPort(preferredPort int) error {
|
||
return c.startInternal(preferredPort)
|
||
}
|
||
|
||
func (c *ServerController) startInternal(preferredPort int) error {
|
||
c.txMu.Lock()
|
||
defer c.txMu.Unlock()
|
||
|
||
// 1. 先檢查狀態
|
||
c.mu.Lock()
|
||
s := c.state
|
||
c.mu.Unlock()
|
||
if s == ServerStateRunning || s == ServerStateStarting || s == ServerStateStopping {
|
||
return fmt.Errorf("cannot start: current state=%s", s)
|
||
}
|
||
|
||
// 2. 清理殘留 process(Error state 進來時常見)
|
||
c.mu.Lock()
|
||
if c.proc != nil {
|
||
oldProc := c.proc
|
||
c.proc = nil
|
||
c.mu.Unlock()
|
||
oldProc.stopGraceful() // 不發通知、快速清理
|
||
} else {
|
||
c.mu.Unlock()
|
||
}
|
||
|
||
// 3. 切到 Starting
|
||
c.setState(ServerStateStarting, "")
|
||
|
||
// 3.5. 非冷啟動路徑(pipeline 已完成或已失敗)自動重建 pipeline,讓前端
|
||
// 5 階段 UI 能跟著 Restart / StopServer+StartServer 更新狀態。
|
||
// cold-start(pipeline current 在 [1..6])不動,避免打斷正在進行中的
|
||
// 正常啟動流程。
|
||
// - current > totalStages (7) → markReady 已完成 → Restart 路徑
|
||
// - current == -1 → FailStage 已停 → Retry 路徑(但這條
|
||
// 一般走 RestartStartupSequence 不走這裡)
|
||
//
|
||
// didRebuild 記錄本次是不是走 rebuild 路徑;是的話成功返回前會呼叫
|
||
// runStartupStage5 把 Stage 5 openBrowser + CompleteStage 跑完,後續
|
||
// Stage 6 由 watcher 的 sentinel poll 觸發 markReady。
|
||
didRebuild := false
|
||
if c.app != nil && c.app.startupPipeline != nil {
|
||
pl := c.app.startupPipeline
|
||
pl.mu.Lock()
|
||
cur := pl.current
|
||
pl.mu.Unlock()
|
||
needRebuild := cur <= 0 || cur > startupTotalStages
|
||
if needRebuild {
|
||
// 清 sentinel file(和 RestartStartupSequence 一樣,避免階段 6 誤判)
|
||
removeSentinelFile(c.app.dataDir)
|
||
c.app.rebuildStartupPipeline()
|
||
didRebuild = true
|
||
}
|
||
}
|
||
|
||
// 4. 真的啟動
|
||
proc, err := c.app.startServerV2(preferredPort)
|
||
if err != nil {
|
||
// M8-4b 補丁 M-1:若 startup pipeline 已 FailStage(`pipeline.emitError` 已經
|
||
// setState(Error) + 發過 OS 通知),這條 fallback 路徑 skip 一次設定,避免:
|
||
// - server:state-change event 連發兩次(payload 相同、前端無傷但冗餘)
|
||
// - 使用者看到「啟動失敗」+「Server 啟動失敗」兩個不同的 OS 通知,誤以為
|
||
// 發生兩次獨立的錯誤
|
||
//
|
||
// 非冷啟動路徑(RestartServer 等 pipeline 已 ready,current==7)或 pipeline==nil
|
||
// 場景下,fallback 仍需要自己 setState + emit + 通知。
|
||
pipelineHandled := false
|
||
if c.app != nil && c.app.startupPipeline != nil {
|
||
pipelineHandled = c.app.startupPipeline.HasFailedStage()
|
||
}
|
||
|
||
if !pipelineHandled {
|
||
c.setState(ServerStateError, err.Error())
|
||
if c.app.ctx != nil {
|
||
wailsRuntime.EventsEmit(c.app.ctx, "server:error", map[string]any{
|
||
"reason": err.Error(),
|
||
})
|
||
}
|
||
// R5-D1:OS 通知
|
||
go sendCrashNotification(
|
||
"visionA Agent — Server 啟動失敗",
|
||
"請打開 visionA Agent 查看錯誤詳情或按 Restart 重試。",
|
||
)
|
||
}
|
||
return err
|
||
}
|
||
|
||
c.mu.Lock()
|
||
c.proc = proc
|
||
c.mu.Unlock()
|
||
c.setState(ServerStateRunning, "")
|
||
|
||
// 5. Stage 5 openBrowser 處理
|
||
//
|
||
// 三條路徑的分流(本段以 didRebuild 和 IsInColdStart 區分):
|
||
// (A) 冷啟動 (app.startup → ctrl.Start):didRebuild=false,
|
||
// IsInColdStart=true(current 仍在 2-4 範圍)→ 這段 skip,由
|
||
// app.startup 後續呼叫 runStartupStage5 負責
|
||
// (B) Restart / StopServer+StartServer:didRebuild=true(剛 rebuild
|
||
// 過 pipeline),IsInColdStart=true → 呼叫 runStartupStage5 完整
|
||
// 跑 Stage 5 + Stage 6 watcher
|
||
// (C) RestartStartupSequence:呼叫上層 ctrl.Start(此函式),didRebuild
|
||
// = false(pipeline 已由 RestartStartupSequence 自己 rebuild),
|
||
// IsInColdStart=true → skip,由 RestartStartupSequence 後續呼叫
|
||
// runStartupStage5 負責
|
||
// (D) 舊有 fallback:pipeline == nil 或意外情境 → 自己 openBrowser 一次
|
||
if didRebuild {
|
||
// Restart 路徑:自己跑 Stage 5(也會 CompleteStage(5) → Stage 6 watcher
|
||
// 會 poll sentinel → 最後 markReady,前端 5 階段面板會完整跑完)
|
||
c.app.runStartupStage5()
|
||
} else {
|
||
// 冷啟動和 RestartStartupSequence 由上層呼叫 runStartupStage5
|
||
inColdStart := false
|
||
if c.app != nil && c.app.startupPipeline != nil {
|
||
inColdStart = c.app.startupPipeline.IsInColdStart()
|
||
}
|
||
if !inColdStart && c.app.prefs.AutoOpenBrowser && proc != nil && proc.port > 0 {
|
||
url := fmt.Sprintf("http://127.0.0.1:%d", proc.port)
|
||
if err := openBrowser(url); err != nil {
|
||
fmt.Fprintf(os.Stderr, "[visiona-agent] auto-open browser failed: %v\n", err)
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Stop 優雅停止 server。7 秒 grace + 1 秒 modal event。
|
||
func (c *ServerController) Stop() error {
|
||
c.txMu.Lock()
|
||
defer c.txMu.Unlock()
|
||
|
||
// MAJ-1 修復:先 cancel watchServerV2 goroutine,避免它在 server 死後仍 poll
|
||
// health endpoint,3 次失敗(30s)後誤觸 handleWatchFailure → setState(Error)
|
||
// → 發 OS 崩潰通知。使用者按 Stop 後不該收到「Server 崩潰」警報。
|
||
c.cancelWatcher()
|
||
|
||
c.mu.Lock()
|
||
s := c.state
|
||
proc := c.proc
|
||
c.mu.Unlock()
|
||
|
||
if s == ServerStateStopped || s == ServerStateError || s == ServerStateIdle {
|
||
// 沒東西可停
|
||
return nil
|
||
}
|
||
if s == ServerStateStarting {
|
||
// 理論上 txMu 已經保證不會併發,但保險起見
|
||
return fmt.Errorf("cannot stop while starting; retry shortly")
|
||
}
|
||
|
||
// 切到 Stopping(必須先 set,讓前端按鈕 disable)
|
||
c.setState(ServerStateStopping, "")
|
||
|
||
if proc != nil {
|
||
proc.stopGraceful()
|
||
}
|
||
|
||
c.mu.Lock()
|
||
c.proc = nil
|
||
c.mu.Unlock()
|
||
c.setState(ServerStateStopped, "")
|
||
return nil
|
||
}
|
||
|
||
// cancelWatcher 取消正在跑的 watchServerV2 goroutine,並把 a.watchCancel 清掉。
|
||
// 必須在 txMu 已被持有的情況下呼叫(避免與 startServerV2 重新建立 watcher 競態)。
|
||
//
|
||
// MAJ-1 修復用 helper:Stop / ForceKill 進場後第一件事就呼叫此函式,
|
||
// 確保 watcher 不會在 transition 期間 race 地翻動 state。
|
||
func (c *ServerController) cancelWatcher() {
|
||
if c.app == nil {
|
||
return
|
||
}
|
||
c.app.mu.Lock()
|
||
if c.app.watchCancel != nil {
|
||
c.app.watchCancel()
|
||
c.app.watchCancel = nil
|
||
}
|
||
c.app.mu.Unlock()
|
||
}
|
||
|
||
// Restart = Stop → Start,強制保留舊 port(R5-F-2)。
|
||
func (c *ServerController) Restart() error {
|
||
// 記住舊 port
|
||
c.mu.Lock()
|
||
oldPort := 0
|
||
if c.proc != nil {
|
||
oldPort = c.proc.port
|
||
}
|
||
c.mu.Unlock()
|
||
|
||
// MAJ-4 補丁:通知瀏覽器 tab「server 要 restart 了」
|
||
// 前端收到 reason=restart → 延遲 10 秒再顯示 Offline Overlay,給 server 重啟時間。
|
||
// best-effort:失敗不影響 Stop / Start 流程。
|
||
if c.app != nil && oldPort > 0 {
|
||
ctx := context.Background()
|
||
if c.app.ctx != nil {
|
||
ctx = c.app.ctx
|
||
}
|
||
notifyShutdownImminent(ctx, oldPort, "restart")
|
||
}
|
||
|
||
if err := c.Stop(); err != nil {
|
||
return err
|
||
}
|
||
return c.StartWithPort(oldPort)
|
||
}
|
||
|
||
// ForceKill 非 graceful 直接 SIGKILL 目前的 proc(給 RestartStartupSequence 用)。
|
||
// 不 emit 崩潰通知,因為這是使用者主動要的「清乾淨重來」。
|
||
//
|
||
// 呼叫後 state → Stopped。
|
||
func (c *ServerController) ForceKill() error {
|
||
c.txMu.Lock()
|
||
defer c.txMu.Unlock()
|
||
|
||
// MAJ-1 修復:與 Stop() 同理,先 cancel watcher,避免 ForceKill 後 watcher
|
||
// 還在跑 → 30 秒後把 Stopped 翻成 Error + 發崩潰通知。
|
||
c.cancelWatcher()
|
||
|
||
c.mu.Lock()
|
||
proc := c.proc
|
||
c.proc = nil
|
||
c.mu.Unlock()
|
||
|
||
if proc != nil {
|
||
proc.forceKill()
|
||
}
|
||
c.setState(ServerStateStopped, "")
|
||
return nil
|
||
}
|
||
|
||
// handleWatchFailure 是 watchServer goroutine 偵測到連續失敗時呼叫的。
|
||
// 進 Error state + OS 通知,不 os.Exit。
|
||
//
|
||
// MAJ-2 修復:必須持有 txMu,與 Start/Stop/ForceKill/Restart 序列化,避免:
|
||
// - Stop 已把 state → Stopped,watcher 又把它翻回 Error
|
||
// - ForceKill 已把 state → Stopped,watcher 又翻回 Error 並發崩潰通知
|
||
//
|
||
// 進場後若 state 已不是 Running(代表使用者剛主動 Stop/ForceKill/Restart 完成),
|
||
// 直接 return — watcher 的失敗結論已過時。
|
||
//
|
||
// 死結風險評估:watchServerV2 是獨立 goroutine,由 startServerV2 啟動後與
|
||
// 啟動流程脫鉤,不會在持有 txMu 的情況下呼叫 handleWatchFailure,因此安全。
|
||
func (c *ServerController) handleWatchFailure(port int, reason string) {
|
||
c.txMu.Lock()
|
||
defer c.txMu.Unlock()
|
||
|
||
c.mu.Lock()
|
||
curState := c.state
|
||
proc := c.proc
|
||
c.mu.Unlock()
|
||
|
||
// 二次檢查:已不是 Running 代表 Stop / ForceKill / Restart 在我們等 txMu 時搶先了。
|
||
// 不能覆蓋已完成的 transition。
|
||
if curState != ServerStateRunning {
|
||
return
|
||
}
|
||
|
||
c.setState(ServerStateError, reason)
|
||
if c.app.ctx != nil {
|
||
wailsRuntime.EventsEmit(c.app.ctx, "server:error", map[string]any{
|
||
"reason": reason,
|
||
"port": port,
|
||
})
|
||
}
|
||
// 非阻塞發 OS 通知
|
||
go sendCrashNotification(
|
||
"visionA Agent — Server 崩潰",
|
||
"本機伺服器停止回應。請打開 visionA Agent 查看錯誤並按 Restart。",
|
||
)
|
||
|
||
// 嘗試清理 process(可能還沒死)
|
||
if proc != nil {
|
||
proc.forceKill()
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// ServerProcess v2:擁有 pipe readers 與 pump 管線
|
||
// -----------------------------------------------------------------------
|
||
|
||
// stopGraceful 執行 7 秒 grace + 1 秒 modal 的 shutdown 流程。
|
||
// 對應 TDD v2/server-lifecycle.md §8.2 的 ServerProcess.stop()。
|
||
//
|
||
// 注意:這是 v2 新增的方法,與 v1 的 ServerProcess.stop() 並存以免動到太多舊路徑。
|
||
// v2 路徑(ctrl.Stop 呼叫)一律走這個。
|
||
func (p *ServerProcess) stopGraceful() {
|
||
if p == nil || p.cmd == nil || p.cmd.Process == nil {
|
||
return
|
||
}
|
||
pid := p.cmd.Process.Pid
|
||
if p.app != nil {
|
||
p.app.appLog("stopGraceful: entered pid=%d", pid)
|
||
}
|
||
// Windows 沒有 SIGTERM,直接 Kill
|
||
if runtime.GOOS == "windows" {
|
||
_ = p.cmd.Process.Kill()
|
||
} else {
|
||
_ = p.cmd.Process.Signal(syscall.SIGTERM)
|
||
}
|
||
|
||
done := make(chan struct{})
|
||
go func() {
|
||
_, _ = p.cmd.Process.Wait()
|
||
close(done)
|
||
}()
|
||
|
||
modalTimer := time.NewTimer(1 * time.Second)
|
||
graceTimer := time.NewTimer(shutdownGraceV2)
|
||
defer modalTimer.Stop()
|
||
defer graceTimer.Stop()
|
||
|
||
// 追蹤 modal 是否曾 show,return 時需要對稱地 hide(避免前端卡在
|
||
// 「正在停止伺服器…」popup)。
|
||
modalShown := false
|
||
defer func() {
|
||
if p.app != nil {
|
||
p.app.appLog("stopGraceful: return pid=%d modalShown=%v", pid, modalShown)
|
||
}
|
||
if modalShown && p.app != nil && p.app.ctx != nil {
|
||
wailsRuntime.EventsEmit(p.app.ctx, "shutdown:modal-hide", nil)
|
||
}
|
||
}()
|
||
|
||
// Watchdog:無論哪個 branch,最多 graceTimer + 2 秒 後強制離開,避免
|
||
// `<-done` 永遠阻塞(Windows 上 Process.Wait 偶有情況不 return)。
|
||
// 2 秒是額外 safety margin,用 timer.After 實作,不改動主 select。
|
||
hardBailout := time.NewTimer(shutdownGraceV2 + 2*time.Second)
|
||
defer hardBailout.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-done:
|
||
p.closeLogFiles()
|
||
return
|
||
case <-modalTimer.C:
|
||
if p.app != nil && p.app.ctx != nil {
|
||
wailsRuntime.EventsEmit(p.app.ctx, "shutdown:modal-show", nil)
|
||
modalShown = true
|
||
p.app.appLog("stopGraceful: modal-show emitted")
|
||
}
|
||
case <-graceTimer.C:
|
||
if p.app != nil {
|
||
p.app.appLog("stopGraceful: grace timer, force-kill pid=%d", pid)
|
||
}
|
||
_ = p.cmd.Process.Kill()
|
||
// 非阻塞等 done,最多 1 秒(防止 Windows Wait 卡死)
|
||
select {
|
||
case <-done:
|
||
case <-time.After(1 * time.Second):
|
||
if p.app != nil {
|
||
p.app.appLog("stopGraceful: Process.Wait did not return within 1s after Kill, leaking")
|
||
}
|
||
}
|
||
p.closeLogFiles()
|
||
return
|
||
case <-hardBailout.C:
|
||
// 絕對上限:無論如何都要離開
|
||
if p.app != nil {
|
||
p.app.appLog("stopGraceful: hard bailout hit, leaking process pid=%d", pid)
|
||
}
|
||
p.closeLogFiles()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// forceKill 立即 SIGKILL,不 graceful、不 emit event。
|
||
func (p *ServerProcess) forceKill() {
|
||
if p == nil || p.cmd == nil || p.cmd.Process == nil {
|
||
return
|
||
}
|
||
_ = p.cmd.Process.Kill()
|
||
_, _ = p.cmd.Process.Wait()
|
||
p.closeLogFiles()
|
||
}
|
||
|
||
// closeLogFiles 安全關閉 stdout/stderr log 檔。
|
||
func (p *ServerProcess) closeLogFiles() {
|
||
if p.stdoutLog != nil {
|
||
_ = p.stdoutLog.Close()
|
||
p.stdoutLog = nil
|
||
}
|
||
if p.stderrLog != nil {
|
||
_ = p.stderrLog.Close()
|
||
p.stderrLog = nil
|
||
}
|
||
}
|
||
|
||
// shutdownGraceV2 = 7 秒 grace period(PM Q4)。
|
||
const shutdownGraceV2 = 7 * time.Second
|
||
|
||
// -----------------------------------------------------------------------
|
||
// App.startServerV2 — 使用 pipe 捕捉 stdout/stderr
|
||
// -----------------------------------------------------------------------
|
||
|
||
// startServerV2 是 v2 版本的 server spawn,改為:
|
||
// 1. 使用 StdoutPipe / StderrPipe 讓 logPump 能逐行讀
|
||
// 2. 開兩個 goroutine 做 logPump(寫檔 + append LogBuffer + emit event)
|
||
// 3. 健康檢查成功後才 writeIPCPort
|
||
// 4. M8-4b:穿插 startupPipeline.CompleteStage(2..4) 的 hook
|
||
//
|
||
// preferredPort:
|
||
// 0 → cold start / manual Start,允許從 defaultPreferredPort 開始 fallback
|
||
// 非 0 → 強制指定 port(Restart 路徑),port 被佔就失敗
|
||
//
|
||
// 成功回傳 *ServerProcess(含 port / cmd / pipes);失敗回 nil + error。
|
||
//
|
||
// pipeline 注意事項:startServerV2 同時被冷啟動(pipeline 還在跑)和 RestartServer
|
||
// (pipeline 已 ready,hook 會被 CompleteStage 內的 current!=stage 檢查擋下)共用。
|
||
// 因此呼叫 hook 是 idempotent / 安全的。
|
||
func (a *App) startServerV2(preferredPort int) (*ServerProcess, error) {
|
||
// 1. 決定 Python runtime — 對應啟動階段 2「檢查 Python runtime」
|
||
pyBin, pyMode, err := a.ensurePythonRuntime(a.pythonMode)
|
||
if err != nil {
|
||
// 階段 2 失敗
|
||
if a.startupPipeline != nil {
|
||
a.startupPipeline.FailStage(2, fmt.Errorf("python runtime unavailable: %w", err))
|
||
}
|
||
return nil, fmt.Errorf("python runtime unavailable: %w", err)
|
||
}
|
||
a.mu.Lock()
|
||
a.pythonBin = pyBin
|
||
a.pythonModeR = pyMode
|
||
a.mu.Unlock()
|
||
// 階段 2 完成 → 自動進入階段 3 running
|
||
if a.startupPipeline != nil {
|
||
a.startupPipeline.CompleteStage(2)
|
||
}
|
||
|
||
// 2. 首次啟動自動安裝 Kneron WinUSB driver(Windows only)
|
||
// 邏輯上發生在 Stage 2 與 Stage 3 之間(pipeline current 已切到 3),
|
||
// 所以 emit 到 stage 3 的 detail,避免 driver detail 被前端忽略。
|
||
if pyBin != "" {
|
||
if a.startupPipeline != nil && runtime.GOOS == "windows" {
|
||
a.startupPipeline.EmitStageDetail(3, "startup.stage.2.detail.driver", 0)
|
||
}
|
||
if derr := a.ensureDriverInstalled(pyBin); derr != nil {
|
||
a.appLog("driver auto-install failed (non-fatal): %v", derr)
|
||
}
|
||
}
|
||
|
||
// 3. 挑 port
|
||
var port int
|
||
if preferredPort > 0 {
|
||
// Restart 路徑:強制保留,不 fallback
|
||
if !portAvailable(preferredPort) {
|
||
// 先試著清掉 stale 的 visiona-agent-server
|
||
if killStaleServerOnPort(preferredPort) {
|
||
time.Sleep(500 * time.Millisecond)
|
||
}
|
||
}
|
||
if !portAvailable(preferredPort) {
|
||
return nil, fmt.Errorf("Restart failed: port %d occupied", preferredPort)
|
||
}
|
||
port = preferredPort
|
||
} else {
|
||
p, perr := pickPort(defaultPreferredPort)
|
||
if perr != nil {
|
||
return nil, fmt.Errorf("no free port: %w", perr)
|
||
}
|
||
port = p
|
||
}
|
||
|
||
// 4. 定位 server binary
|
||
binPath, err := locateServerBinary()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("server binary not found: %w", err)
|
||
}
|
||
|
||
// 5. 組參數
|
||
args := []string{
|
||
"--host", "127.0.0.1",
|
||
"--port", strconv.Itoa(port),
|
||
"--data-dir", a.dataDir,
|
||
"--python-mode", string(pyMode),
|
||
}
|
||
if pyBin != "" {
|
||
args = append(args, "--python", pyBin)
|
||
}
|
||
|
||
// 6. 開磁碟 log 檔(append 模式)
|
||
logsDir := filepath.Join(a.dataDir, "logs")
|
||
_ = os.MkdirAll(logsDir, 0o755)
|
||
stdoutLog, _ := os.OpenFile(filepath.Join(logsDir, "server.stdout.log"),
|
||
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||
stderrLog, _ := os.OpenFile(filepath.Join(logsDir, "server.stderr.log"),
|
||
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||
|
||
// 7. 組 cmd
|
||
cmd := exec.Command(binPath, args...)
|
||
cmd.Dir = filepath.Dir(binPath)
|
||
configureSysProcAttr(cmd)
|
||
|
||
// 注入 bundle bin dir(ffmpeg / ffprobe)
|
||
env := os.Environ()
|
||
if binDir, err := locateBundleBinDir(); err == nil {
|
||
env = append(env, "VISIONA_BUNDLE_BIN_DIR="+binDir)
|
||
}
|
||
if pyBin != "" {
|
||
env = append(env, "VISIONA_PYTHON="+pyBin)
|
||
}
|
||
cmd.Env = env
|
||
|
||
// 8. v2:用 Pipe 而不是 cmd.Stdout = file
|
||
stdoutPipe, err := cmd.StdoutPipe()
|
||
if err != nil {
|
||
closeLogFilesSafe(stdoutLog, stderrLog)
|
||
return nil, fmt.Errorf("StdoutPipe: %w", err)
|
||
}
|
||
stderrPipe, err := cmd.StderrPipe()
|
||
if err != nil {
|
||
_ = stdoutPipe.Close()
|
||
closeLogFilesSafe(stdoutLog, stderrLog)
|
||
return nil, fmt.Errorf("StderrPipe: %w", err)
|
||
}
|
||
|
||
// 9. 啟動 process
|
||
if a.startupPipeline != nil && a.startupPipeline.IsInColdStart() {
|
||
a.startupPipeline.EmitStageDetail(3, "startup.stage.3.detail.spawn", 0)
|
||
}
|
||
if err := cmd.Start(); err != nil {
|
||
_ = stdoutPipe.Close()
|
||
_ = stderrPipe.Close()
|
||
closeLogFilesSafe(stdoutLog, stderrLog)
|
||
return nil, fmt.Errorf("exec.Start: %w", err)
|
||
}
|
||
|
||
proc := &ServerProcess{
|
||
cmd: cmd,
|
||
port: port,
|
||
stdoutLog: stdoutLog,
|
||
stderrLog: stderrLog,
|
||
app: a,
|
||
}
|
||
|
||
// 10. 啟動兩個 logPump goroutine
|
||
go a.logPump(stdoutPipe, "stdout", stdoutLog)
|
||
go a.logPump(stderrPipe, "stderr", stderrLog)
|
||
|
||
// 11. 等 health check — 對應啟動階段 3「啟動本機伺服器」
|
||
//
|
||
// 冷啟動時 pause hard timeout:Windows 首次執行 visiona-agent-server.exe 會被
|
||
// Defender / EDR real-time scan 卡 30-120 秒,healthCheckTimeout 本身 180 秒足夠
|
||
// 涵蓋大多數情境,但不該把這段 scan 時間算進 startup pipeline 的 60 秒 total
|
||
// budget(那是日常啟動預算,首次 bootstrap 應豁免,和 Stage 2 Python bootstrap
|
||
// 同理)。
|
||
//
|
||
// 判斷冷啟動:pipeline 處於 stage 1-6 範圍內(IsInColdStart)。
|
||
// RestartServer(pipeline 已 ready,current==7)不 pause,維持嚴格計時。
|
||
pausedForWait := false
|
||
if a.startupPipeline != nil && a.startupPipeline.IsInColdStart() {
|
||
a.startupPipeline.PauseHardTimeout()
|
||
pausedForWait = true
|
||
// Stage 3 sub-step 提示:告訴使用者 server binary 已送出,正在等待啟動
|
||
a.startupPipeline.EmitStageDetail(3, "startup.stage.3.detail.waitHealth", 0)
|
||
}
|
||
|
||
// waitHealthy progress callback:每 5 秒更新一次 stage-hint 顯示已等待時間;
|
||
// 等待 >= 15 秒後改顯示 slow hint(首次啟動 Defender 掃描較慢是正常情況)。
|
||
waitProgress := func(elapsed int) {
|
||
if a.startupPipeline == nil {
|
||
return
|
||
}
|
||
key := "startup.stage.3.detail.waitHealth"
|
||
if elapsed >= 15 {
|
||
key = "startup.stage.3.detail.waitHealthSlow"
|
||
}
|
||
a.startupPipeline.EmitStageDetail(3, key, elapsed)
|
||
}
|
||
|
||
if err := waitHealthy(port, healthCheckTimeout, waitProgress); err != nil {
|
||
if pausedForWait {
|
||
a.startupPipeline.ResumeHardTimeout()
|
||
}
|
||
proc.forceKill()
|
||
removeIPCPort(a.dataDir)
|
||
// 階段 3 失敗
|
||
if a.startupPipeline != nil {
|
||
a.startupPipeline.FailStage(3, fmt.Errorf("server did not become healthy: %w", err))
|
||
}
|
||
return nil, fmt.Errorf("server did not become healthy: %w", err)
|
||
}
|
||
if pausedForWait {
|
||
a.startupPipeline.ResumeHardTimeout()
|
||
}
|
||
// 階段 3 完成 → 自動進入階段 4 running
|
||
if a.startupPipeline != nil {
|
||
a.startupPipeline.CompleteStage(3)
|
||
}
|
||
|
||
// 12. 寫 ipc-port 檔
|
||
writeIPCPort(a.dataDir, port)
|
||
|
||
// 12.5. M8-4b:階段 4「偵測 Kneron 裝置」— 探測 /api/devices 一次。
|
||
// 任何 HTTP response(含 5xx 或 timeout)都視為「server 已能 serve」,階段 4 完成。
|
||
// 真正的 device list 內容由前端負責處理(空 list 也算正常)。
|
||
if a.startupPipeline != nil {
|
||
a.probeDeviceListAndComplete(port)
|
||
}
|
||
|
||
// 13. 啟動 watchServer(失敗進 Error state)
|
||
watchCtx, cancel := context.WithCancel(context.Background())
|
||
a.mu.Lock()
|
||
if a.watchCancel != nil {
|
||
a.watchCancel()
|
||
}
|
||
a.watchCancel = cancel
|
||
a.mu.Unlock()
|
||
go a.watchServerV2(watchCtx, proc)
|
||
|
||
return proc, nil
|
||
}
|
||
|
||
// probeDeviceListAndComplete 對 /api/devices 發一次 GET 作為「偵測 Kneron 裝置」階段,
|
||
// 不論回應內容與狀態碼,只要拿到 HTTP response 就算階段 4 完成(我們只是想確認
|
||
// server 已能 serve 業務 endpoint,硬體有沒有插不重要 — 空 list 也合法)。
|
||
//
|
||
// timeout 設 2 秒。TDD §3 原意是「秒回即算完成」;實測 /api/devices handler 讀內部
|
||
// registry,毫秒級就回,2 秒 timeout 足以涵蓋正常 latency,不需要 5 秒保守值。
|
||
// 逾時也算「偵測完成」(避免硬體 driver 卡住整個啟動流程)。
|
||
func (a *App) probeDeviceListAndComplete(port int) {
|
||
if a.startupPipeline == nil {
|
||
return
|
||
}
|
||
a.startupPipeline.EmitStageDetail(4, "startup.stage.4.detail.probe", 0)
|
||
url := fmt.Sprintf("http://127.0.0.1:%d/api/devices", port)
|
||
client := &http.Client{Timeout: 2 * time.Second}
|
||
resp, err := client.Get(url)
|
||
|
||
// Linux udev rule 自動偵測:解析 response body 檢查 udevHint
|
||
if runtime.GOOS == "linux" && resp != nil && resp.StatusCode == 200 {
|
||
a.checkAndInstallUdevRule(resp)
|
||
} else if resp != nil {
|
||
resp.Body.Close()
|
||
}
|
||
|
||
if err != nil {
|
||
a.appLog("startup stage 4: device probe non-fatal error: %v", err)
|
||
}
|
||
a.startupPipeline.CompleteStage(4)
|
||
}
|
||
|
||
// checkAndInstallUdevRule 在 Linux 啟動流程中偵測 udev rule 是否需要安裝。
|
||
// 解析 /api/devices response body,如果 udevHint=true → 自動嘗試安裝:
|
||
// 1. 先從 bundle 讀取 99-kneron.rules 到 /tmp
|
||
// 2. 用 pkexec cp 提權複製到 /etc/udev/rules.d/(彈密碼框)
|
||
// 3. pkexec udevadm reload + trigger
|
||
// 成功後 appLog 提示使用者拔插裝置。失敗不阻擋啟動流程。
|
||
func (a *App) checkAndInstallUdevRule(resp *http.Response) {
|
||
defer resp.Body.Close()
|
||
|
||
var body struct {
|
||
Data struct {
|
||
UdevHint bool `json:"udevHint"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||
return
|
||
}
|
||
if !body.Data.UdevHint {
|
||
return
|
||
}
|
||
|
||
a.appLog("Linux udev rule 未安裝,正在嘗試自動安裝...")
|
||
a.startupPipeline.EmitStageDetail(4, "startup.stage.4.detail.udev", 0)
|
||
|
||
// 找 bundle 裡的 99-kneron.rules
|
||
ruleSrc := ""
|
||
if libDir := os.Getenv("VISIONA_BUNDLE_LIB_DIR"); libDir != "" {
|
||
candidate := filepath.Join(libDir, "99-kneron.rules")
|
||
if _, err := os.Stat(candidate); err == nil {
|
||
ruleSrc = candidate
|
||
}
|
||
}
|
||
if ruleSrc == "" {
|
||
candidates := []string{
|
||
"installer/linux/99-kneron.rules",
|
||
"../installer/linux/99-kneron.rules",
|
||
}
|
||
for _, c := range candidates {
|
||
if _, err := os.Stat(c); err == nil {
|
||
abs, _ := filepath.Abs(c)
|
||
ruleSrc = abs
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if ruleSrc == "" {
|
||
a.appLog("udev rule 來源檔找不到,跳過自動安裝")
|
||
return
|
||
}
|
||
|
||
// AppImage FUSE mount 的檔案在 pkexec 提權後無法被 root 讀取,
|
||
// 先 cp 到 /tmp 再 pkexec 從 /tmp 安裝。
|
||
tmpRule := "/tmp/visiona-agent-99-kneron.rules"
|
||
data, err := os.ReadFile(ruleSrc)
|
||
if err != nil {
|
||
a.appLog("udev rule 讀取失敗:%v", err)
|
||
return
|
||
}
|
||
if err := os.WriteFile(tmpRule, data, 0o644); err != nil {
|
||
a.appLog("udev rule 寫 /tmp 失敗:%v", err)
|
||
return
|
||
}
|
||
defer os.Remove(tmpRule)
|
||
|
||
cpCmd := exec.Command("pkexec", "cp", tmpRule, "/etc/udev/rules.d/99-kneron.rules")
|
||
if out, err := cpCmd.CombinedOutput(); err != nil {
|
||
a.appLog("udev rule 安裝失敗(使用者可能取消了密碼輸入):%v (%s)", err, strings.TrimSpace(string(out)))
|
||
return
|
||
}
|
||
|
||
_ = exec.Command("pkexec", "udevadm", "control", "--reload-rules").Run()
|
||
_ = exec.Command("pkexec", "udevadm", "trigger").Run()
|
||
|
||
a.appLog("udev rule 安裝成功。請拔掉 Kneron USB 裝置再重新插入,然後在 Web UI 點「掃描裝置」。")
|
||
}
|
||
|
||
func closeLogFilesSafe(files ...*os.File) {
|
||
for _, f := range files {
|
||
if f != nil {
|
||
_ = f.Close()
|
||
}
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// logPump:pipe → LogBuffer → Wails event(10ms micro-batch)
|
||
// -----------------------------------------------------------------------
|
||
|
||
// logPump 讀取 server 子程序的 stdout 或 stderr pipe,
|
||
// 每行同時:
|
||
// 1. 寫到磁碟 log 檔
|
||
// 2. append 到 ring buffer
|
||
// 3. 累積到 10ms micro-batch,flush 時 emit "log:append" Wails event
|
||
//
|
||
// TDD ground truth:v2/control-panel.md §4.5、v2/server-lifecycle.md §4.2。
|
||
//
|
||
// Pipe EOF(process exit)或 scanner 錯誤時 goroutine 自行退出。
|
||
func (a *App) logPump(pipe io.ReadCloser, stream string, fileWriter io.Writer) {
|
||
defer pipe.Close()
|
||
|
||
scanner := bufio.NewScanner(pipe)
|
||
scanner.Buffer(make([]byte, 64*1024), 1*1024*1024)
|
||
|
||
batch := make([]LogLine, 0, 16)
|
||
ticker := time.NewTicker(10 * time.Millisecond)
|
||
defer ticker.Stop()
|
||
|
||
// 拆出 scanner goroutine,讓 main loop 可以 select 於 ticker + lineCh
|
||
scanDone := make(chan struct{})
|
||
lineCh := make(chan string, 128)
|
||
|
||
go func() {
|
||
defer close(scanDone)
|
||
for scanner.Scan() {
|
||
text := scanner.Text()
|
||
select {
|
||
case lineCh <- text:
|
||
default:
|
||
// channel full:同步 block,避免 scanner 跑太快讓 batch 丟太多行
|
||
// 寧可 scanner 背壓一下,也要保留資料完整性
|
||
lineCh <- text
|
||
}
|
||
}
|
||
}()
|
||
|
||
flush := func() {
|
||
if len(batch) == 0 {
|
||
return
|
||
}
|
||
if a.ctx != nil && a.logBuf != nil {
|
||
// Rate limit:burst 超過就不 emit,但 ring buffer 已 append 過
|
||
if a.logBuf.ShouldEmit() {
|
||
wailsRuntime.EventsEmit(a.ctx, "log:append", batch)
|
||
}
|
||
}
|
||
batch = batch[:0]
|
||
}
|
||
|
||
// processLine 把單行寫檔 + append ring buffer + 加進 batch。
|
||
// MAJ-5 修復後 main loop 與 drain loop 共用此 helper,確保兩條路徑語意一致。
|
||
processLine := func(line string) {
|
||
// 1. 寫檔(持久化)
|
||
if fileWriter != nil {
|
||
_, _ = fileWriter.Write([]byte(line + "\n"))
|
||
}
|
||
// 2. ring buffer
|
||
l := LogLine{
|
||
Ts: time.Now().UnixMilli(),
|
||
Stream: stream,
|
||
Line: line,
|
||
Level: parseLogLevel(line),
|
||
}
|
||
if a.logBuf != nil {
|
||
a.logBuf.Append(l)
|
||
}
|
||
// 3. 加到 batch
|
||
batch = append(batch, l)
|
||
}
|
||
|
||
for {
|
||
select {
|
||
case line, ok := <-lineCh:
|
||
if !ok {
|
||
flush()
|
||
return
|
||
}
|
||
processLine(line)
|
||
case <-ticker.C:
|
||
flush()
|
||
case <-scanDone:
|
||
// MAJ-5 修復:scanner goroutine 已結束(pipe EOF / process exit),
|
||
// 但 lineCh buffer(cap 128)內可能還有未消化的行 — 包括 server crash
|
||
// 時最後幾行 stderr stack trace。Go select 隨機選擇 ready case,
|
||
// 直接 return 會導致這些行被丟棄。
|
||
//
|
||
// 修法:drain lineCh 到底再 flush + return。drain 用 default 避免
|
||
// 永遠阻塞(scanner goroutine 已 close → 沒人會再寫入,drain 必能結束)。
|
||
ticker.Stop()
|
||
drain:
|
||
for {
|
||
select {
|
||
case line := <-lineCh:
|
||
processLine(line)
|
||
default:
|
||
break drain
|
||
}
|
||
}
|
||
flush()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// watchServerV2 — 取代 watchServer,失敗時進 Error state
|
||
// -----------------------------------------------------------------------
|
||
|
||
// watchServerV2 每 10 秒打 /api/system/health,連續 3 次失敗進 Error state。
|
||
// 不再呼叫 reportFatal / os.Exit。
|
||
func (a *App) watchServerV2(ctx context.Context, sp *ServerProcess) {
|
||
ticker := time.NewTicker(10 * time.Second)
|
||
defer ticker.Stop()
|
||
|
||
failures := 0
|
||
healthURL := fmt.Sprintf("http://127.0.0.1:%d/api/system/health", sp.port)
|
||
client := &http.Client{Timeout: 3 * time.Second}
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
resp, err := client.Get(healthURL)
|
||
if err == nil && resp.StatusCode == 200 {
|
||
resp.Body.Close()
|
||
if failures > 0 && a.ctx != nil {
|
||
wailsRuntime.EventsEmit(a.ctx, "server:recovered", nil)
|
||
}
|
||
failures = 0
|
||
continue
|
||
}
|
||
if resp != nil {
|
||
resp.Body.Close()
|
||
}
|
||
failures++
|
||
fmt.Fprintf(os.Stderr, "[visiona-agent] server health check failed (%d/3): %v\n", failures, err)
|
||
if failures >= 3 {
|
||
if a.ctrl != nil {
|
||
a.ctrl.handleWatchFailure(sp.port, "health check failed 3 times")
|
||
}
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Bindings — Wails 自動暴露給前端
|
||
// -----------------------------------------------------------------------
|
||
|
||
// DEPRECATED_IN_AGENT: StartServer / StopServer / RestartServer / ForceKillServer /
|
||
// RestartStartupSequence 已移除。
|
||
//
|
||
// 背景:local-tool 把這些 method 綁到前端,讓使用者從 UI 控制 server 生命週期
|
||
// (Retry / Restart / Force kill)。visionA Agent 的 3 個配置頁(狀態 / 配對 /
|
||
// 設定)不需要由使用者直接操作 server — server 只由 tunnel 轉進來的雲端請求驅動,
|
||
// 使用者不感知 server 存在。
|
||
//
|
||
// 內部仍然透過 a.ctrl.Start() / a.ctrl.Stop() 等 controller method 控制生命週期,
|
||
// 但不再透過 Wails binding 暴露給前端。測試若需要觸發啟停,直接呼叫
|
||
// a.ctrl.Start() / a.ctrl.Stop()。
|
||
|
||
// rebuildStartupPipeline 把 startup pipeline 重置為「Stage 1 completed +
|
||
// Stage 2 running」的初始狀態並啟動 watcher。
|
||
//
|
||
// 呼叫時機:
|
||
// 1. RestartStartupSequence — 使用者按 Retry 按鈕時 recover failure
|
||
// 2. startInternal — 非冷啟動路徑進場時,若 pipeline 已完成或失敗則
|
||
// 重建,讓 Restart / StopServer+StartServer 也能讓前端 5 階段 UI 更新
|
||
//
|
||
// 前置:呼叫者必須先停舊 watcher goroutine(a.pipelineCancelFn)+ 清
|
||
// sentinel file(removeSentinelFile)。本函式只負責重建 + 前進到 stage 2。
|
||
func (a *App) rebuildStartupPipeline() {
|
||
// 停舊 watcher(防禦:呼叫者沒清時保險)
|
||
if a.pipelineCancelFn != nil {
|
||
a.pipelineCancelFn()
|
||
a.pipelineCancelFn = nil
|
||
}
|
||
|
||
// 新建 pipeline instance
|
||
a.startupPipeline = NewStartupPipeline(a)
|
||
|
||
// 階段 1「初始化 Wails 控制台」已是 running 狀態(Wails app 本身),
|
||
// 不需要重做,直接 emit completed 然後手動切到階段 2 running
|
||
now := time.Now()
|
||
a.startupPipeline.mu.Lock()
|
||
a.startupPipeline.startedAt = now
|
||
a.startupPipeline.current = 1
|
||
a.startupPipeline.stages[1].status = "completed"
|
||
a.startupPipeline.stages[1].startedAt = now
|
||
a.startupPipeline.stages[1].completedAt = now
|
||
a.startupPipeline.mu.Unlock()
|
||
a.startupPipeline.emitProgress(1)
|
||
|
||
// 啟動 watcher goroutine
|
||
if a.ctx != nil {
|
||
watcherCtx, cancel := context.WithCancel(a.ctx)
|
||
a.pipelineCancelFn = cancel
|
||
a.startupPipeline.watcherCancel = cancel
|
||
a.startupPipeline.watcherDone = make(chan struct{})
|
||
go a.startupPipeline.watcher(watcherCtx)
|
||
}
|
||
|
||
// 切到階段 2 running
|
||
a.startupPipeline.mu.Lock()
|
||
a.startupPipeline.current = 2
|
||
a.startupPipeline.stages[2].status = "running"
|
||
a.startupPipeline.stages[2].startedAt = time.Now()
|
||
a.startupPipeline.mu.Unlock()
|
||
a.startupPipeline.emitProgress(2)
|
||
}
|
||
|
||
// GetServerStatusV2 回傳 v2 版本的完整狀態。
|
||
// 注意:v1 的 GetServerStatus() 仍保留以維持相容性,但內容為舊 struct。
|
||
func (a *App) GetServerStatusV2() ServerStatusV2 {
|
||
return a.snapshotStatus()
|
||
}
|
||
|
||
// snapshotStatus 是組裝 ServerStatusV2 的工具函式(內部使用)。
|
||
func (a *App) snapshotStatus() ServerStatusV2 {
|
||
a.mu.Lock()
|
||
pyBin := a.pythonBin
|
||
pyMode := string(a.pythonModeR)
|
||
a.mu.Unlock()
|
||
|
||
st := ServerStatusV2{
|
||
PythonBin: pyBin,
|
||
PythonMode: pyMode,
|
||
}
|
||
if a.ctrl != nil {
|
||
a.ctrl.mu.Lock()
|
||
st.State = a.ctrl.state
|
||
st.LastError = a.ctrl.lastError
|
||
if !a.ctrl.startedAt.IsZero() {
|
||
st.StartedAt = a.ctrl.startedAt.UnixMilli()
|
||
}
|
||
proc := a.ctrl.proc
|
||
a.ctrl.mu.Unlock()
|
||
if proc != nil && proc.cmd != nil && proc.cmd.Process != nil {
|
||
st.Port = proc.port
|
||
st.URL = fmt.Sprintf("http://127.0.0.1:%d", proc.port)
|
||
st.PID = proc.cmd.Process.Pid
|
||
}
|
||
} else {
|
||
st.State = ServerStateIdle
|
||
}
|
||
return st
|
||
}
|
||
|
||
// GetRecentLogs 回傳 ring buffer 最後 n 行。
|
||
// n <= 0 或 > 2000 → 回傳全部。
|
||
func (a *App) GetRecentLogs(n int) []LogLine {
|
||
if a.logBuf == nil {
|
||
return []LogLine{}
|
||
}
|
||
return a.logBuf.Snapshot(n)
|
||
}
|
||
|
||
// DEPRECATED_IN_AGENT: ClearLogs 已移除。visionA Agent 的 3 個配置頁不提供
|
||
// 清空 log 按鈕;使用者只能看 RecentLog(狀態頁)或匯出 log(設定頁)。
|
||
// 真的需要清 buffer 的時機請呼叫 a.logBuf.Reset() 內部 method。
|
||
|
||
// GetSystemInfo 回傳靜態系統資訊。
|
||
func (a *App) GetSystemInfo() SystemInfo {
|
||
logsDir := ""
|
||
if a.dataDir != "" {
|
||
logsDir = filepath.Join(a.dataDir, "logs")
|
||
}
|
||
return SystemInfo{
|
||
AppVersion: appVersionString(),
|
||
BuildTime: appBuildTimeString(),
|
||
DataDir: a.dataDir,
|
||
LogsDir: logsDir,
|
||
Platform: runtime.GOOS + "/" + runtime.GOARCH,
|
||
}
|
||
}
|
||
|
||
// DEPRECATED_IN_AGENT: OpenInBrowser / RevealLogsFolder 已移除。
|
||
//
|
||
// OpenInBrowser:local-tool 靠它讓使用者開「http://127.0.0.1:3721」到瀏覽器用 Web
|
||
// UI;visionA Agent 的雲端 UI 是遠端的,沒有「開瀏覽器到本機」的需求。
|
||
//
|
||
// RevealLogsFolder:AB2+AB3 決定不讓使用者從 UI 直接跳到 logs/ 檔案總管;
|
||
// 設定頁提供「匯出 log zip」就夠了(ExportLog 仍保留)。
|
||
|
||
// ExportLog 把 ring buffer + logs/ 資料夾打包成 zip 並回傳絕對路徑。
|
||
//
|
||
// AB10 更新:從單純文字檔改成 zip(對應 Design spec §6.2.3「匯出 Log」)。
|
||
//
|
||
// zip 內容:
|
||
// - `ring-buffer.txt` — 目前 in-memory ring buffer 快照(人類可讀)
|
||
// - `logs/*.log` — logs/ 目錄內所有檔案(wails.log、server stdout/stderr)
|
||
//
|
||
// 輸出位置:
|
||
// - 雛形:OS temp dir,檔名 `visionA-agent-log-YYYYMMDD-HHmmss.zip`
|
||
// - 前端拿到 path 後可呼叫 SaveFileDialog 讓使用者選擇儲存位置(AF6 實作)
|
||
//
|
||
// 失敗情境:
|
||
// - logBuf 未初始化 → error
|
||
// - dataDir 為空 → 仍繼續(只 zip ring buffer)
|
||
// - 部分檔案讀不到 → 跳過該檔並在 ring-buffer.txt 記一行 WARN;整個 export 不中斷
|
||
func (a *App) ExportLog() (string, error) {
|
||
if a.logBuf == nil {
|
||
return "", fmt.Errorf("log buffer not initialized")
|
||
}
|
||
|
||
ts := time.Now().Format("20060102-150405")
|
||
zipPath := filepath.Join(os.TempDir(), fmt.Sprintf("visionA-agent-log-%s.zip", ts))
|
||
|
||
zf, err := os.Create(zipPath)
|
||
if err != nil {
|
||
return "", fmt.Errorf("create zip file: %w", err)
|
||
}
|
||
defer zf.Close()
|
||
|
||
zw := zip.NewWriter(zf)
|
||
defer zw.Close()
|
||
|
||
// 1. 寫 ring buffer 快照為 ring-buffer.txt
|
||
if err := writeRingBufferToZip(zw, a.logBuf); err != nil {
|
||
return "", fmt.Errorf("write ring buffer: %w", err)
|
||
}
|
||
|
||
// 2. 把 logs/ 資料夾下所有檔案塞進 zip
|
||
if a.dataDir != "" {
|
||
logsDir := filepath.Join(a.dataDir, "logs")
|
||
if err := addDirToZip(zw, logsDir, "logs"); err != nil {
|
||
// 非致命——ring buffer 已經寫進去了
|
||
a.appLog("ExportLog: partial failure adding logs dir: %v", err)
|
||
}
|
||
}
|
||
|
||
// 顯式 close zip writer(defer 會再 close 一次但是 no-op)
|
||
if err := zw.Close(); err != nil {
|
||
return "", fmt.Errorf("close zip writer: %w", err)
|
||
}
|
||
|
||
abs, absErr := filepath.Abs(zipPath)
|
||
if absErr != nil {
|
||
return zipPath, nil
|
||
}
|
||
return abs, nil
|
||
}
|
||
|
||
// writeRingBufferToZip 把 ring buffer 快照寫入 zip 中的 `ring-buffer.txt`。
|
||
func writeRingBufferToZip(zw *zip.Writer, buf *LogBuffer) error {
|
||
w, err := zw.Create("ring-buffer.txt")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
snap := buf.Snapshot(0)
|
||
for _, line := range snap {
|
||
t := time.UnixMilli(line.Ts).Format("2006-01-02 15:04:05.000")
|
||
var prefix string
|
||
if line.Level != "" {
|
||
prefix = fmt.Sprintf("[%s] %s [%s] ", line.Level, t, line.Stream)
|
||
} else {
|
||
prefix = fmt.Sprintf("%s [%s] ", t, line.Stream)
|
||
}
|
||
if _, err := w.Write([]byte(prefix + line.Line + "\n")); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// addDirToZip 遞迴把 srcDir 下所有檔案放入 zip 的 prefix/ 底下。
|
||
// srcDir 不存在時直接 return nil(使用者可能首次啟動就按匯出)。
|
||
func addDirToZip(zw *zip.Writer, srcDir, prefix string) error {
|
||
info, err := os.Stat(srcDir)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil
|
||
}
|
||
return err
|
||
}
|
||
if !info.IsDir() {
|
||
return nil
|
||
}
|
||
return filepath.Walk(srcDir, func(path string, fi os.FileInfo, walkErr error) error {
|
||
if walkErr != nil {
|
||
return walkErr
|
||
}
|
||
if fi.IsDir() {
|
||
return nil
|
||
}
|
||
rel, err := filepath.Rel(srcDir, path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// zip 內的路徑用正斜線(跨平台標準)
|
||
zipName := prefix + "/" + filepath.ToSlash(rel)
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer f.Close()
|
||
w, err := zw.Create(zipName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if _, err := io.Copy(w, f); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// GetPreferences 讀取 Preferences(in-memory)。
|
||
func (a *App) GetPreferences() Preferences {
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
return a.prefs
|
||
}
|
||
|
||
// SetPreferences 更新並持久化 Preferences。
|
||
func (a *App) SetPreferences(p Preferences) error {
|
||
if a.dataDir == "" {
|
||
return fmt.Errorf("data dir not ready")
|
||
}
|
||
if err := SavePreferences(a.dataDir, p); err != nil {
|
||
return err
|
||
}
|
||
a.mu.Lock()
|
||
a.prefs = p
|
||
a.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 版本字串 placeholder(M8 目前沒有 build 時注入版本,給一個合理預設)
|
||
// -----------------------------------------------------------------------
|
||
|
||
func appVersionString() string {
|
||
if v := os.Getenv("VISIONA_APP_VERSION"); v != "" {
|
||
return v
|
||
}
|
||
return "dev"
|
||
}
|
||
|
||
func appBuildTimeString() string {
|
||
if v := os.Getenv("VISIONA_BUILD_TIME"); v != "" {
|
||
return v
|
||
}
|
||
return "unknown"
|
||
}
|