visionA/local-tool/visiona-local/startup_pipeline.go
jim800121chen 9c9e005d33 feat(local-tool): Stage 3 sub-step 進度 + 啟動完成後面板可收合
回應使用者三項需求:
1. healthCheckTimeout 60s → 180s(涵蓋 Defender + EDR 串行延遲最壞情境)
2. Stage 3「啟動本機伺服器」期間顯示細步在做什麼,並在 15 秒後改為「首次
   啟動較久屬正常」slow hint,避免使用者看著 spinner 不動以為 app 掛了
3. 啟動完成後 6 階段面板自動收合成一行 summary,使用者點擊可展開檢視歷
   史紀錄;Restart / Retry 會重置並展開新一輪

實作:

Go 端
- healthCheckTimeout 60s → 180s(理由註解寫清楚 Defender + EDR 各自延遲)
- waitHealthy() 加 progress callback,每 5 秒呼叫一次傳入 elapsedSeconds
- StartupPipeline 加 StartupStageDetailEvent + EmitStageDetail() API
- startServerV2 在 spawn 前 emit detail.spawn,等 health check 期間 callback
  emit detail.waitHealth(< 15s)或 detail.waitHealthSlow(>= 15s)

前端
- 新訂 startup:stage-detail event → updateStageDetail() 把 i18n key 解析為
  文案存到 stages[n].detail,paintStageRow 優先顯示 detail(蓋過 slow hint)
- collapseStartupPanel() / expandStartupPanel() / resetStartupPanel() 三個新
  API 取代 hideStartupPanel;startup:ready 觸發 collapse、Retry/Restart 觸
  發 reset+expand
- collapsed CSS:保留 panel 但縮成一行 summary(標題改「啟動完成」+ ✓ +
  「點此展開檢視」hint),整個 panel 可點擊;hover 加亮
- i18n 加 6 個 keys(zh-TW + en)

驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 重 build 163MB OK
- 乾淨 dataDir 啟動 wails app:startup 1 秒內完成(macOS 已 cache binary
  + Python venv),server listen 3721,Chrome 自動連上 — 整條 cold start
  正常

Windows 首次安裝預期行為(修復後):
- Stage 1 → Stage 2(首次 bootstrap pause hard timeout,跑 1-3 分鐘)→ Stage
  3 spawn → 等 health check 30-90 秒(Defender 掃 binary)期間有「已等 N
  秒」即時更新 → ready → 自動 collapse → 瀏覽器自動開啟

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 00:17:37 +08:00

525 lines
18 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.

package main
// startup_pipeline.go — M8-4b6 階段啟動進度 + soft/hard timeout + watcher
//
// TDD ground truth
// - .autoflow/04-architecture/v2/startup-pipeline.md518 行完整規格)
// - R5-E1 ~ R5-E6AC-1.3 從 10 秒硬指標 → 60 秒 + 階段化進度)
//
// 設計重點:
// 1. 6 個階段1 = Wails console / 2 = Python runtime / 3 = server / 4 = devices /
// 5 = open browser / 6 = wait Web UI WebSocket
// 2. soft timeout = 20 秒每階段emit "startup:stage-timeout" 但不中斷
// 3. hard timeout = 60 秒總時emit "startup:error" + 進 Error state
// 4. 階段 5/6 在 AutoOpenBrowser=false 時:
// - 階段 5 → status="skipped"
// - 階段 6 → 不檢查 timeout使用者必須手動點 Open in Browser 才會建立 WebSocket
// 5. 階段 6 透過 sentinel file `<dataDir>/.first-ws-connected` 偵測;
// server 端 Hub 在第一個 WS client 連上時寫檔(見 server/internal/api/ws/hub.go
// 6. RestartStartupSequence bindingRetry 按鈕用5 步驟重置整個流程
//
// 1-indexed stages陣列多配一格避免 off-by-onestages[1] ~ stages[6] 使用。
// current sentinel 值0 = 未啟動、1-6 = 進行中、7 = ready、-1 = 已失敗。
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// -----------------------------------------------------------------------
// 常量
// -----------------------------------------------------------------------
const (
startupTotalStages = 6
startupSoftTimeout = 20 * time.Second
startupHardTimeout = 60 * time.Second
startupWatcherTick = 1 * time.Second
)
// startupSentinelFileName 是 server 端寫入、Wails 端 poll 的檔名。
// 路徑:<dataDir>/.first-ws-connected
const startupSentinelFileName = ".first-ws-connected"
// -----------------------------------------------------------------------
// Event payload
// -----------------------------------------------------------------------
// StartupProgressEvent 對應 startup-pipeline.md §1.1。
// 每階段 status 變化running / completed / failed / skipped都會 emit 一次。
type StartupProgressEvent struct {
Stage int `json:"stage"` // 1-6
TotalStages int `json:"totalStages"` // 固定 6
LabelKey string `json:"labelKey"` // 如 "startup.stage.2.label"
Status string `json:"status"` // "pending" | "running" | "completed" | "failed" | "skipped"
StartedAt int64 `json:"startedAt"` // Unix ms該階段 startedAt
}
// StartupStageTimeoutEvent — soft timeout 提示,不中斷流程,只 emit 一次/階段。
type StartupStageTimeoutEvent struct {
Stage int `json:"stage"`
SoftTimeoutSeconds int `json:"softTimeoutSeconds"` // 固定 20
}
// StartupErrorEvent — 階段失敗或總時 hard timeout。emit 後 pipeline 停止。
type StartupErrorEvent struct {
Stage int `json:"stage"`
Error string `json:"error"`
Cause string `json:"cause"` // "stage-failure" | "total-timeout"
}
// StartupStageDetailEvent — 階段內細步進度提示,讓使用者看到當下在做什麼。
// 不影響 stage status只是 UI 上 stage-hint 的文案來源。
// DetailKey 是 i18n key前端查表顯示ElapsedSeconds > 0 時附在文案後當耗時提示。
type StartupStageDetailEvent struct {
Stage int `json:"stage"`
DetailKey string `json:"detailKey"` // i18n key, e.g. "startup.stage.3.detail.waitHealth"
ElapsedSeconds int `json:"elapsedSeconds"` // 0 時不顯示耗時
}
// -----------------------------------------------------------------------
// stageState — 單一階段的內部狀態
// -----------------------------------------------------------------------
type stageState struct {
status string // "pending" | "running" | "completed" | "failed" | "skipped"
startedAt time.Time
completedAt time.Time
softTimeoutEmitted bool
}
// -----------------------------------------------------------------------
// StartupPipeline — 主 struct
// -----------------------------------------------------------------------
// StartupPipeline 管理 6 階段啟動流程。
// 與 ServerController 解耦StartupPipeline 只 emit Wails event
// 它對 server lifecycle 的影響由 ServerController透過 emitError → setState負責。
type StartupPipeline struct {
app *App
mu sync.Mutex
stages [startupTotalStages + 1]stageState // 1-indexed
current int // 0=未啟動、1-6=進行中、7=ready、-1=failed
startedAt time.Time
// Hard timeout pause 機制(首次 Python bootstrap 專用)。
//
// R5-E1 的 60 秒 hard timeout 預算是針對「日常啟動」,不含首次安裝 Python
// runtime解壓 ~15MB tarball + 建 venv + pip install 9 個 wheel 含 numpy /
// opencv 合計 ~150MB這種一次性 bootstrap 工作——那可能花 2-5 分鐘。
//
// 解法:讓 app.go 在真正需要首次 bootstrap 時pythonBin 不存在)呼叫
// PauseHardTimeout(),完成後呼叫 ResumeHardTimeout()。暫停期間不算進
// sinceTotal避開 hard timeout。Soft timeout 繼續照常(每階段 20 秒的
// 視覺提示不受影響,使用者仍會看到「正在重試...」hint
//
// 只暫停 hard timeout不暫停整體時鐘——因為 soft timeout 的語意是
// 「單一階段停滯太久」,首次 bootstrap 確實會停滯,該提示就該提示。
pausedDuration time.Duration // 累積暫停總時
pauseStartedAt time.Time // 當前暫停開始時間zero = 未暫停
// watcher goroutine 控制。pipelineCancelFn 由 app.go 持有;本 struct 只記 done channel。
watcherCancel context.CancelFunc
watcherDone chan struct{}
}
// NewStartupPipeline 建立新的 pipeline。並未啟動 watcher必須呼叫 Start。
func NewStartupPipeline(app *App) *StartupPipeline {
return &StartupPipeline{
app: app,
current: 0,
}
}
// Start 啟動整個 pipeline從階段 1 開始 emit running並開啟 watcher goroutine。
// 只能呼叫一次(重複呼叫會建立多個 watcher 是合法的,但會浪費資源 — 上層應避免)。
func (p *StartupPipeline) Start(ctx context.Context) {
p.mu.Lock()
p.startedAt = time.Now()
p.current = 1
p.stages[1].status = "running"
p.stages[1].startedAt = time.Now()
p.mu.Unlock()
p.emitProgress(1)
watcherCtx, cancel := context.WithCancel(ctx)
p.watcherCancel = cancel
p.watcherDone = make(chan struct{})
go p.watcher(watcherCtx)
}
// CompleteStage 標記 stage 為 completed並進入下一階段若還有
// 若 stage == startupTotalStages → 觸發 markReady。
//
// 順序錯誤(重複呼叫或階段不對)→ 安靜 ignore避免被 race condition 害到。
func (p *StartupPipeline) CompleteStage(stage int) {
p.mu.Lock()
if p.current != stage || p.current <= 0 {
p.mu.Unlock()
return
}
p.stages[stage].status = "completed"
p.stages[stage].completedAt = time.Now()
p.mu.Unlock()
p.emitProgress(stage)
if stage == startupTotalStages {
p.markReady()
return
}
// 進入下一階段
p.mu.Lock()
next := stage + 1
p.current = next
p.stages[next].status = "running"
p.stages[next].startedAt = time.Now()
p.mu.Unlock()
p.emitProgress(next)
}
// SkipStage 標記 stage 為 skipped並進入下一階段。
// 用於:階段 5 在 prefs.AutoOpenBrowser=false 時跳過 OpenInBrowser 呼叫。
// Watcher 看到 status=skipped → 不檢查 soft timeout也不檢查 hard timeout
func (p *StartupPipeline) SkipStage(stage int) {
p.mu.Lock()
if p.current != stage || p.current <= 0 {
p.mu.Unlock()
return
}
p.stages[stage].status = "skipped"
p.stages[stage].completedAt = time.Now()
p.mu.Unlock()
p.emitProgress(stage)
if stage == startupTotalStages {
p.markReady()
return
}
p.mu.Lock()
next := stage + 1
p.current = next
p.stages[next].status = "running"
p.stages[next].startedAt = time.Now()
p.mu.Unlock()
p.emitProgress(next)
}
// PauseHardTimeout 暫停 hard timeout 計時。
// 用於首次 Python bootstrap可能花 2-5 分鐘解壓 + pip install期間。
// 重複呼叫安全:第二次呼叫會先 Resume 再 Pause避免累積偏差。
// 呼叫者app.go ensureBundledPython 的「真正 bootstrap」路徑。
func (p *StartupPipeline) PauseHardTimeout() {
p.mu.Lock()
defer p.mu.Unlock()
// 已在暫停 → 先結束前一次暫停區間再開新的(保持精確)
if !p.pauseStartedAt.IsZero() {
p.pausedDuration += time.Since(p.pauseStartedAt)
}
p.pauseStartedAt = time.Now()
}
// ResumeHardTimeout 結束 hard timeout 暫停。
// 若未處於暫停狀態則 no-op不回 error避免上層要做空檢查
func (p *StartupPipeline) ResumeHardTimeout() {
p.mu.Lock()
defer p.mu.Unlock()
if p.pauseStartedAt.IsZero() {
return
}
p.pausedDuration += time.Since(p.pauseStartedAt)
p.pauseStartedAt = time.Time{}
}
// effectiveSinceTotal 回傳扣除暫停時間後的總時。watcher 用這個判斷 hard timeout。
// 呼叫者必須已持有 p.mu。
func (p *StartupPipeline) effectiveSinceTotalLocked() time.Duration {
total := time.Since(p.startedAt) - p.pausedDuration
if !p.pauseStartedAt.IsZero() {
// 目前正在暫停中 → 扣掉當前暫停區間
total -= time.Since(p.pauseStartedAt)
}
if total < 0 {
total = 0
}
return total
}
// FailStage 標記 stage 為 failedpipeline 停止並進 Error state。
//
// 副作用(透過 emitError
// - emit "startup:error" Wails event
// - 若 ctrl 存在 → setState(Error)(前端會收到 server:state-change
// - 發 OS 通知R5-D1
func (p *StartupPipeline) FailStage(stage int, err error) {
p.mu.Lock()
if p.current <= 0 {
p.mu.Unlock()
return
}
p.stages[stage].status = "failed"
p.current = -1
p.mu.Unlock()
p.emitProgress(stage)
p.emitError(stage, err, "stage-failure")
p.stopWatcher()
}
// markReady 6 階段都完成後觸發。emit "startup:ready" 並停 watcher。
func (p *StartupPipeline) markReady() {
p.mu.Lock()
p.current = startupTotalStages + 1
p.mu.Unlock()
if p.app != nil && p.app.ctx != nil {
// 同步 emit成功路徑沒有 IPC backlog 風險)
wailsRuntime.EventsEmit(p.app.ctx, "startup:ready", nil)
}
p.stopWatcher()
}
// emitProgress 取當前 stage 狀態 snapshot 並 emit "startup:progress" event。
// 用 goroutine 包起來避免 Wails IPC 慢拖累呼叫者CompleteStage / SkipStage 等)。
func (p *StartupPipeline) emitProgress(stage int) {
p.mu.Lock()
if stage <= 0 || stage > startupTotalStages {
p.mu.Unlock()
return
}
st := p.stages[stage]
p.mu.Unlock()
if p.app == nil || p.app.ctx == nil {
return
}
go func() {
wailsRuntime.EventsEmit(p.app.ctx, "startup:progress", StartupProgressEvent{
Stage: stage,
TotalStages: startupTotalStages,
LabelKey: fmt.Sprintf("startup.stage.%d.label", stage),
Status: st.status,
StartedAt: st.startedAt.UnixMilli(),
})
}()
}
// EmitStageDetail 在 stage 進行中送一條細步提示文字到前端,讓使用者看到
// 「當下在做什麼」。不改 stage status只更新 UI 上的 stage-hint 欄位。
//
// 呼叫點server_control.go 裡的 startServerV2 會在 spawn binary / 等 health
// check / 30 秒後 slow hint 等節點呼叫這個函式,對應到 i18n key
// startup.stage.3.detail.spawn # 正在啟動 server 子程序
// startup.stage.3.detail.waitHealth # 正在等 server 健康檢查通過
// startup.stage.3.detail.waitHealthSlow # 首次啟動 Defender 掃描可能需時 1-2 分鐘
//
// elapsedSeconds > 0 時前端會在文案後顯示已等時長。
func (p *StartupPipeline) EmitStageDetail(stage int, detailKey string, elapsedSeconds int) {
if p == nil || p.app == nil || p.app.ctx == nil {
return
}
go func() {
wailsRuntime.EventsEmit(p.app.ctx, "startup:stage-detail", StartupStageDetailEvent{
Stage: stage,
DetailKey: detailKey,
ElapsedSeconds: elapsedSeconds,
})
}()
}
// emitError emit "startup:error" 並通知 ctrl 進 Error state + 發 OS 通知。
// cause 取值:"stage-failure" | "total-timeout"
func (p *StartupPipeline) emitError(stage int, err error, cause string) {
if p.app == nil {
return
}
if p.app.ctx != nil {
go func() {
wailsRuntime.EventsEmit(p.app.ctx, "startup:error", StartupErrorEvent{
Stage: stage,
Error: err.Error(),
Cause: cause,
})
}()
}
// 同步通知 ServerController 進 Error state
if p.app.ctrl != nil {
p.app.ctrl.setState(ServerStateError, err.Error())
}
// R5-D1發 OS 通知fire-and-forget
go sendCrashNotification(
"visionA Local — 啟動失敗",
fmt.Sprintf("第 %d 階段失敗:%s", stage, err.Error()),
)
}
// watcher 每秒 tick
// 1. 階段 6 時檢查 sentinel file → 存在則 CompleteStage(6)
// 2. 檢查 hard timeout總時 > 60 s→ FailStage + emitError(total-timeout)
// 3. 檢查 soft timeout單一階段 > 20 s→ emit "startup:stage-timeout"
//
// skip timeout 規則:
// - 該階段已 skipped → 完全不檢查 soft / hard
// - 階段 6 + AutoOpenBrowser=false → 完全不檢查 soft / hard
//
// 退出條件ctx.Done()、current 已不在 1-6 範圍ready 或 failed
func (p *StartupPipeline) watcher(ctx context.Context) {
defer close(p.watcherDone)
ticker := time.NewTicker(startupWatcherTick)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
p.mu.Lock()
if p.current <= 0 || p.current > startupTotalStages {
p.mu.Unlock()
return
}
cur := p.current
st := p.stages[cur]
curStatus := st.status
sinceStage := time.Since(st.startedAt)
// Hard timeout 走扣除暫停時間的 effective 時鐘soft timeout 照常
// 用實際 stage startedAt —— 這是故意的:首次 bootstrap 時使用者
// 仍會看到 20 秒「正在重試」hint但不會在 60 秒時被強制 fail。
sinceTotal := p.effectiveSinceTotalLocked()
softEmitted := st.softTimeoutEmitted
p.mu.Unlock()
// 階段 6每次 tick 檢查 sentinel file
if cur == 6 {
if p.checkSentinelFile() {
p.CompleteStage(6)
return
}
}
// skip timeout 判斷
skipTimeout := false
if curStatus == "skipped" {
skipTimeout = true
}
if cur == 6 && p.app != nil && !p.app.prefs.AutoOpenBrowser {
skipTimeout = true
}
// hard timeout總時 > 60 s
if !skipTimeout && sinceTotal > startupHardTimeout {
err := fmt.Errorf("startup total timeout: %s > %s", sinceTotal, startupHardTimeout)
// 直接 mark failed + emit 兩個 event不走 FailStage 因為 cause 不一樣
p.mu.Lock()
p.stages[cur].status = "failed"
p.current = -1
p.mu.Unlock()
p.emitProgress(cur)
p.emitError(cur, err, "total-timeout")
p.stopWatcher()
return
}
if skipTimeout {
continue
}
// soft timeout單一階段 > 20 s
if sinceStage > startupSoftTimeout && !softEmitted {
p.mu.Lock()
p.stages[cur].softTimeoutEmitted = true
p.mu.Unlock()
if p.app != nil && p.app.ctx != nil {
stageRef := cur
go func() {
wailsRuntime.EventsEmit(p.app.ctx, "startup:stage-timeout", StartupStageTimeoutEvent{
Stage: stageRef,
SoftTimeoutSeconds: int(startupSoftTimeout.Seconds()),
})
}()
}
}
}
}
}
// checkSentinelFile 檢查 <dataDir>/.first-ws-connected 是否存在。
// 存在 → 階段 6 完成server 端的 WebSocket Hub 寫了這個檔,代表第一個 client 已連上)
func (p *StartupPipeline) checkSentinelFile() bool {
if p.app == nil || p.app.dataDir == "" {
return false
}
path := filepath.Join(p.app.dataDir, startupSentinelFileName)
_, err := os.Stat(path)
return err == nil
}
// stopWatcher 主動取消 watcher goroutine。重複呼叫安全cancel 之後再 cancel 是 no-op
func (p *StartupPipeline) stopWatcher() {
if p.watcherCancel != nil {
p.watcherCancel()
}
}
// HasFailedStage 回傳 pipeline 是否已有任何階段被標記為 failed。
//
// M8-4b 補丁M-1 修復startInternal 用這個判斷「pipeline 是否已經 FailStage 過」,
// 如果是則 skip 自己的 sendCrashNotification / setState(Error),避免使用者看到兩個
// 獨立的錯誤通知(一個由 pipeline.emitError 發、一個由 startInternal fallback 發)。
//
// 語義current == -1整個 pipeline 已停在 failed或任一 stage status == "failed"。
func (p *StartupPipeline) HasFailedStage() bool {
p.mu.Lock()
defer p.mu.Unlock()
if p.current == -1 {
return true
}
for i := 1; i <= startupTotalStages; i++ {
if p.stages[i].status == "failed" {
return true
}
}
return false
}
// IsInColdStart 回傳 pipeline 是否正在「冷啟動中」(stage 1-6 running)。
//
// M8-4b 補丁M-2 修復startInternal 的 R5-D3 openBrowser 邏輯用這個判斷
// 「目前是否走冷啟動 pipeline 路徑」。若是,由 runStartupStage5 / pipeline stage 5 hook
// 負責 openBrowserstartInternal 跳過避免瀏覽器開兩次Linux 會開兩個 tab
//
// 非冷啟動場景:
// - pipeline == nil單測或 Wails 未初始化)
// - current == 0尚未 Start
// - current == 7已 ready屬於 RestartServer 直接 Start 的情境)
// - current == -1已 failed
//
// 這些場景下 startInternal 仍需要自己呼叫 openBrowser。
func (p *StartupPipeline) IsInColdStart() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.current >= 1 && p.current <= startupTotalStages
}
// removeSentinelFile 移除 sentinel file。失敗時不回 error檔案不存在是正常情況
// 由 RestartStartupSequence、StartServer 前置、shutdown 呼叫。
func removeSentinelFile(dataDir string) {
if dataDir == "" {
return
}
path := filepath.Join(dataDir, startupSentinelFileName)
_ = os.Remove(path)
}