jim800121chen 8cd5751ce3 feat(local-tool): M8 重構 — Wails 控制台 + 瀏覽器 Web UI(R5 決策)
依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。

程式碼變動
  - M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
    Makefile vendor / installer / bootstrap / CI workflow,-555 行)
  - M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
    VISIONA_MOCK 環境變數,-528 行)
  - M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
    LGPL binary,macOS 自 build minimal decoder-only 進 git
    (vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
  - M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
    preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
    notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
  - M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
    stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
  - M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
    state 視覺、log panel、startup progress panel、Stage 6 manual CTA
    pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
  - M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
  - M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
    wsEverConnected 容錯 + Page Visibility)
  - M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
    ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
  - MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
    (/ws/system endpoint + notifyShutdownImminent helper)
  - M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)

品質
  - ~105+ 新 unit test + race detector (-count=2) 全綠
  - 10 個 milestone 全部通過 Reviewer 審查
  - 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
    收錄在 .autoflow/

交付前待處理(M8-10)
  - 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
  - 三平台 end-to-end build 驗證

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

29 KiB
Raw Blame History

v2/startup-pipeline.md — R5-E 階段化啟動管線

所屬TDD v2 §2.9v2.1 新增) 版本v2.12026-04-14 R5-E 實作細節) 決策依據R5-E1 ~ R5-E6AC-1.3 從 10 秒硬指標 → 60 秒 + 階段化進度) 對應 milestoneM8-4bBackend Go + Frontend vanilla JS 相關文件:v2/control-panel.md §3.1(啟動進度面板 UIv2/server-lifecycle.md §2.1(冷啟動時間軸)


0. 目的與決策回顧

v2.0 PM §11-2 提問 AC-1.3「10 秒可達性」Architect 樂觀 4 s / 悲觀 8 s / 最壞 11 s。PM 審閱後將此硬指標取代為 R5-E 的新規則:

R5-E 內容
R5-E1 AC-1.3 上限 60 秒(軟硬門檻)
R5-E2 分成 6 個階段,逐階段顯示進度
R5-E3 單一階段卡 > 20 秒soft timeout→ 顯示「階段 N 正在重試」副文字,不中斷流程
R5-E4 總時 > 60 秒hard timeout→ 進 Error state
R5-E5 階段文字由 Design Spec v2.1 決定(本文件只定 event schema + labelKey
R5-E6 「瀏覽器就緒」定義 = WebSocket 連上 serverOnClientConnected 第一次觸發)

v2.0 的樂觀/悲觀估算仍有意義,作為每階段的預算參考(見 server-lifecycle.md §2.1 的階段預算表)。


1. Event Schema

Wails 控制台vanilla JS透過 EventsOn 訂閱以下 4 個 event。Payload 使用 JSON-serializable Go struct。

1.1 startup:progress

每個階段的狀態變化都 emit 一次,可能是 running(階段開始)、completed(階段完成)、failed(階段失敗)。

type StartupProgressEvent struct {
    Stage       int    `json:"stage"`       // 1-6
    TotalStages int    `json:"totalStages"` // 固定 6
    LabelKey    string `json:"labelKey"`    // i18n key見 §2
    Status      string `json:"status"`      // "pending" | "running" | "completed" | "failed" | "skipped"
    StartedAt   int64  `json:"startedAt"`   // Unix ms該階段開始時間
}

Status 值v2.1 二次審閱新增 skipped

  • pending階段尚未開始pipeline render 初始狀態)
  • running階段進行中watcher 會對此階段檢查 soft / hard timeout
  • completed:階段成功完成
  • failed:階段失敗 → pipeline 停止、進 Error state
  • skippedv2.1 新增):該階段依偏好設定或平台規則被跳過,不需執行也不檢查 timeout
    • 目前使用情境:階段 5「開啟瀏覽器」在 prefs.AutoOpenBrowser == falseLinux 預設 OFF或使用者手動關閉→ status=skipped
    • 前端收到 skipped → 顯示 ⏭ 圖示 + 文字「跳過依偏好設定Design Spec v2.1 §4.1 已定)
    • Watcher 看到 skipped 狀態時不檢查 soft timeout進入下一階段的邏輯同 completed

前端 render 邏輯:收到 event → 更新 #startup-stage-<N> DOM 的 status icon 與時間。

1.2 startup:stage-timeout

某階段 > 20 秒soft timeout未完成時 emit 一次(不重複 emit)。僅作提示,不中斷流程。

type StartupStageTimeoutEvent struct {
    Stage              int `json:"stage"`
    SoftTimeoutSeconds int `json:"softTimeoutSeconds"` // 固定 20
}

前端 render 邏輯:收到 event → 在該階段行下方插入 <p class="startup__retry-hint">{i18n("startup.retrying", { stage })}</p>

1.3 startup:error

任一階段失敗或總時 > 60 秒 emit 一次。之後 pipeline 停止,後續不會再有任何 startup:* event。

type StartupErrorEvent struct {
    Stage int    `json:"stage"`
    Error string `json:"error"` // 技術性錯誤訊息
    Cause string `json:"cause"` // "stage-failure" | "total-timeout"
}

前端 render 邏輯:切到 Error state 顯示,同時 server:state-change 會被觸發(因為 ctrl.setState(Error, ...) 已呼叫)。

1.4 startup:ready

6 個階段都 completed 後 emitpayload 為空。

// 無 structEventsEmit(ctx, "startup:ready", nil)

前端 render 邏輯淡出啟動進度面板300 ms ease→ 顯示主控台 UI。


2. i18n Key 清單

文案由 Design Spec v2.1 敲定。TDD 只定義 key 名稱:

Key 用途
startup.title 標題「正在啟動 visionA Local」
startup.stage.1.label 階段 1初始化 Wails 控制台
startup.stage.2.label 階段 2檢查 Python runtime
startup.stage.3.label 階段 3啟動本機伺服器
startup.stage.4.label 階段 4偵測 Kneron 裝置
startup.stage.5.label 階段 5開啟瀏覽器
startup.stage.6.label 階段 6等待 Web UI 連線
startup.retrying 「第 {stage} 階段正在重試 …」副文字
startup.elapsed 「已耗時 {seconds} s · 上限 60 s」
startup.error.stageFailure 「第 {stage} 階段失敗:{error}」
startup.error.totalTimeout 「啟動超過 60 秒,請檢查系統資源」

檔案位置:visiona-local/frontend/i18n/zh-TW.json / en-US.json


3. 6 階段對應的 Go 實作點

# 階段 在哪裡 pipeline.Start(N) 在哪裡 pipeline.Complete(N)
1 初始化 Wails 控制台 app.go:startup 最前面(a.ctx != nil 之後第一行) 同一個 function 的 seedUserDataDir() 返回後
2 檢查 Python runtime startServerV2 開頭、在 ensurePythonRuntime() ensurePythonRuntime() 返回後
3 啟動本機伺服器 階段 2 完成後立即 waitHealthy(port, 30s) 返回後server HTTP OK
4 偵測 Kneron 裝置 階段 3 完成後立即 呼叫 server 的 GET /api/devices 第一次收到 response無論是否有硬體秒回即算完成
5 開啟瀏覽器 階段 4 完成後 呼叫 OpenInBrowser("") 返回後(不等瀏覽器真的開,只等 open/start/xdg-open 命令 returnAutoOpenBrowser=false不呼叫 OpenInBrowser,直接 emit status=skipped 並進入階段 6
6 等待 Web UI 連線 階段 5 完成後 server 的 WebSocket hub OnClientConnected callback 第一次觸發(透過 HTTP 或 channel 通知 Wails

階段 6 的實作細節v2.1 定版sentinel file 方案)

決定採用 sentinel file<dataDir>/.first-ws-connected,理由見下方「為什麼不用 channel / callback 直接串」。

流程

  1. Server 端WebSocket hub 的 OnClientConnected callback 第一次觸發時:

    // server/internal/websocket/hub.go
    func (h *Hub) OnClientConnected(c *Client) {
        h.firstConnOnce.Do(func() {
            sentinelPath := filepath.Join(h.dataDir, ".first-ws-connected")
            f, err := os.Create(sentinelPath)
            if err == nil {
                _, _ = fmt.Fprintf(f, "bootId=%s\nts=%d\n", h.bootID, time.Now().UnixMilli())
                _ = f.Close()
            }
            // 檔案內容存 boot-id + timestamp 便於 debug寫失敗不影響功能Wails 端 poll 不到也會超時 fail
        })
        // ... 其他既有邏輯
    }
    

    使用 sync.Once 確保僅第一次連線觸發;後續連線不重複寫檔。

  2. Wails 端StartupPipeline.watcher goroutine 每秒 tick 時額外檢查 sentinel 檔案:

    // 當 current == 6 時,每次 tick 檢查 sentinel 檔案
    if cur == 6 {
        sentinelPath := filepath.Join(p.app.dataDir, ".first-ws-connected")
        if _, err := os.Stat(sentinelPath); err == nil {
            p.Complete(6) // → markReady() → emit startup:ready
            return
        }
    }
    
  3. 下次 Start 前清檔

    • RestartStartupSequence() 執行時先 os.Remove(sentinelPath)(避免殘留檔案導致新階段 6 瞬間完成)
    • ServerController.Stop() 時也清檔(正常停機的清理)
    • 冷啟動時不需特別清:第一次啟動時檔案不存在,第二次冷啟動前 app 已經整個退出看情況v2.1 決定 每次 StartServer 的前置步驟就呼叫一次 os.Remove(sentinelPath)(最保險,重複 remove 不會錯,反正 os.IsNotExist 當正常情況)

為什麼用 sentinel file 而非 channel / callback 直接串

  • Go server 和 Wails app 是兩個 processserver 是由 exec.Command(server binary, ...) spawn 出來的子程序,跟 Wails 的 Go runtime 完全隔離 → 不能共享 Go channel、mutex 或任何 runtime 記憶體
  • IPC 替代方案比較
    • HTTP long-poll endpoint → 需要佔一個 HTTP connection + 處理 timeout實作較重
    • Unix domain socket / named pipe → 跨平台macOS/Linux/Windows實作差異大Windows 下處理 pipe 權限繁瑣
    • Sentinel file → 跨平台(os.Stat 到處都行)、零依賴、檔案內容可存 boot-id + timestamp 做 debug → 最簡
  • 可觀測性:使用者遇到問題時可以直接檢查 <dataDir>/.first-ws-connected 是否存在,判斷階段 6 是否真的完成

備選方案(若未來 Go server 改為 in-process module不再 spawn 子程序):可以改走 Go channel / callback 直接串屆時在本文件註記即可v2.1 的前提是子程序模型。


4. StartupPipeline Go struct

檔案:visiona-local/startup_pipeline.go

package main

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
    startupWatcherTickMs = 1000
)

type stageState struct {
    status     string // "pending" | "running" | "completed" | "failed"
    startedAt  time.Time
    completedAt time.Time
    softTimeoutEmitted bool
}

type StartupPipeline struct {
    app *App

    mu        sync.Mutex
    stages    [startupTotalStages + 1]stageState // 1-indexed
    current   int                                 // 0 = not started, 1-6 = in progress, 7 = ready, -1 = failed
    startedAt time.Time

    watcherCancel context.CancelFunc
    watcherDone   chan struct{}
}

func NewStartupPipeline(app *App) *StartupPipeline {
    return &StartupPipeline{
        app:     app,
        current: 0,
    }
}

// Start 啟動整個 pipeline從階段 1 開始),並開啟 watcher goroutine。
// 只能呼叫一次。
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)
}

// Complete 標記當前階段完成,並自動切到下一階段(若還有)。
func (p *StartupPipeline) Complete(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 標記某階段為 "skipped",並自動切到下一階段(行為類似 Complete
// v2.1 新增:用於階段 5 在 AutoOpenBrowser=false 時跳過 OpenInBrowser 呼叫。
// Watcher 看到 skipped 狀態時不檢查 soft / 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) // emit status=skipped

    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)
}

// Fail 標記當前階段失敗pipeline 停止。
func (p *StartupPipeline) Fail(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 所有階段完成後觸發。
func (p *StartupPipeline) markReady() {
    p.mu.Lock()
    p.current = startupTotalStages + 1
    p.mu.Unlock()

    if p.app.ctx != nil {
        wailsRuntime.EventsEmit(p.app.ctx, "startup:ready", nil)
    }
    p.stopWatcher()
}

func (p *StartupPipeline) emitProgress(stage int) {
    p.mu.Lock()
    st := p.stages[stage]
    p.mu.Unlock()

    if p.app.ctx == nil {
        return
    }
    // 使用 goroutine + select 避免阻塞(萬一 Wails IPC 慢)
    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(),
        })
    }()
}

func (p *StartupPipeline) emitError(stage int, err error, cause string) {
    if p.app.ctx == nil {
        return
    }
    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 通知
    go sendCrashNotification(
        "visionA Local — 啟動失敗",
        fmt.Sprintf("第 %d 階段失敗:%s", stage, err.Error()),
    )
}

// watcher 每秒檢查 soft timeout、hard timeout、階段 6 sentinel file。
func (p *StartupPipeline) watcher(ctx context.Context) {
    defer close(p.watcherDone)
    ticker := time.NewTicker(startupWatcherTickMs * time.Millisecond)
    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)
            sinceTotal := time.Since(p.startedAt)
            softEmitted := st.softTimeoutEmitted
            p.mu.Unlock()

            // v2.1:階段 6 sentinel file 檢查(取代既有的 OnClientConnected IPC
            if cur == 6 {
                sentinelPath := filepath.Join(p.app.dataDir, ".first-ws-connected")
                if _, err := os.Stat(sentinelPath); err == nil {
                    p.Complete(6)
                    return
                }
            }

            // v2.1skip hard / soft timeout 的情境(必須在 hard timeout 檢查前先判斷)
            //   1) 該階段已標記為 "skipped"(例如階段 5 在 AutoOpenBrowser=false 時)
            //   2) 階段 6 且 AutoOpenBrowser=false → 使用者必須手動點「Open in Browser」才會觸發 WebSocket
            //      連線,等待時間可能很長(使用者去倒杯咖啡),不計入 60 s 上限,也不觸發 soft timeout
            skipTimeout := false
            if curStatus == "skipped" {
                skipTimeout = true
            }
            if cur == 6 && p.app.prefs != nil && !p.app.prefs.AutoOpenBrowser {
                skipTimeout = true
            }

            // Hard timeout總時 > 60 s— 跳過時不檢查
            if !skipTimeout && sinceTotal > startupHardTimeout {
                p.Fail(cur, fmt.Errorf("startup total timeout: %s > %s", sinceTotal, startupHardTimeout))
                p.emitError(cur, fmt.Errorf("total timeout"), "total-timeout")
                return
            }

            if skipTimeout {
                continue // 不檢查 soft timeout
            }

            // Soft timeout單一階段 > 20 s
            if sinceStage > startupSoftTimeout && !softEmitted {
                p.mu.Lock()
                p.stages[cur].softTimeoutEmitted = true
                p.mu.Unlock()

                if p.app.ctx != nil {
                    go func(stage int) {
                        wailsRuntime.EventsEmit(p.app.ctx, "startup:stage-timeout", StartupStageTimeoutEvent{
                            Stage:              stage,
                            SoftTimeoutSeconds: int(startupSoftTimeout.Seconds()),
                        })
                    }(cur)
                }
            }
        }
    }
}

func (p *StartupPipeline) stopWatcher() {
    if p.watcherCancel != nil {
        p.watcherCancel()
    }
}

關鍵設計

  1. 1-indexed stages:陣列多配一格避免 off-by-onestages[1] ~ stages[6] 使用
  2. current sentinel 值0=未啟動、1-6=進行中、7=ready、-1=已失敗watcher 看到非 1-6 立即 return避免 leak
  3. 非阻塞 emit:每個 EventsEmit 都走 go func(),確保 Wails IPC 慢不拖累啟動流程
  4. softTimeoutEmitted flag:確保 startup:stage-timeout 每個階段最多 emit 一次
  5. watcher 生命週期ctx.Done()stopWatcher() 觸發時退出;markReady()Fail() 都會呼叫 stopWatcher()

5. 與 startup(ctx) 的整合

visiona-local/app.gostartup() 函式修改(示意):

func (a *App) startup(ctx context.Context) {
    a.ctx = ctx

    // 初始化階段化啟動管線
    a.pipeline = NewStartupPipeline(a)
    a.pipeline.Start(ctx) // emit startup:progress(stage=1, running)

    // 階段 1既有的初始化
    if err := a.migrateOldDataDirs(); err != nil { ... }
    if err := a.acquireSingleInstance(); err != nil { ... }
    if err := a.startIPCServer(); err != nil { ... }
    if err := a.seedUserDataDir(); err != nil { ... }
    a.pipeline.Complete(1) // emit startup:progress(stage=1, completed) + startup:progress(stage=2, running)

    // 載入 preferences必須在 ctrl.Start 之前ctrl.Start 會讀 prefs.AutoOpenBrowser
    a.prefs = LoadPreferences(a.dataDir)

    // 階段 2-6由 ctrl.Start → startServerV2 內部呼叫 pipeline.Complete(2..6)
    if err := a.ctrl.Start(); err != nil {
        // 失敗情境已由 pipeline.Fail / emitError 處理,這裡不需額外動作
        return
    }
    // 成功pipeline.Complete(6) → markReady() → emit startup:ready
}

server_control.go:startServerV2 內部穿插 pipeline.Complete(N) / pipeline.Start(N+1) 的呼叫,在對應階段的 boundary 執行。


6. 前端Wails 控制台 vanilla JS

檔案:visiona-local/frontend/components/startup-panel.js~100 行)

// startup-panel.js
import { t } from '../i18n/loader.js';

const el = () => document.getElementById('startup-panel');
const stagesEl = () => document.getElementById('startup-stages');
const elapsedEl = () => document.getElementById('startup-elapsed');

let startedAt = 0;
let elapsedTimer = null;

export function initStartupPanel() {
    // 初始 render 6 個階段為 pending
    const container = stagesEl();
    container.innerHTML = '';
    for (let i = 1; i <= 6; i++) {
        const row = document.createElement('div');
        row.id = `startup-stage-${i}`;
        row.className = 'startup__stage startup__stage--pending';
        row.innerHTML = `
            <span class="startup__icon">⏳</span>
            <span class="startup__label">${i}. ${t(`startup.stage.${i}.label`)}</span>
            <span class="startup__time"></span>
            <p class="startup__retry-hint" hidden></p>
        `;
        container.appendChild(row);
    }
    startedAt = Date.now();
    elapsedTimer = setInterval(updateElapsed, 500);
}

function updateElapsed() {
    const seconds = Math.floor((Date.now() - startedAt) / 1000);
    elapsedEl().textContent = t('startup.elapsed', { seconds });
}

export function updateStartupPanel(e) {
    // e = { stage, totalStages, labelKey, status, startedAt, retrying? }
    const row = document.getElementById(`startup-stage-${e.stage}`);
    if (!row) return;
    row.className = `startup__stage startup__stage--${e.status}`;
    const icon = row.querySelector('.startup__icon');
    icon.textContent = {
        pending: '⏳',
        running: '🔄',
        completed: '✅',
        failed: '❌',
        retrying: '🔄',
    }[e.status] || '⏳';

    if (e.status === 'completed' || e.status === 'failed') {
        const elapsed = ((Date.now() - e.startedAt) / 1000).toFixed(1);
        row.querySelector('.startup__time').textContent = `(${elapsed} s)`;
    }

    if (e.status === 'retrying' || e.softTimeoutSeconds) {
        const hint = row.querySelector('.startup__retry-hint');
        hint.textContent = t('startup.retrying', { stage: e.stage });
        hint.hidden = false;
    }
}

export function showStartupError(e) {
    // 最終切 Error state 會由 server:state-change 處理;這裡只做視覺標記
    const row = document.getElementById(`startup-stage-${e.stage}`);
    if (row) {
        row.className = 'startup__stage startup__stage--failed';
        row.querySelector('.startup__icon').textContent = '❌';
    }
    // 停止 elapsed timer
    if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; }
}

export function hideStartupPanel() {
    if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; }
    const panel = el();
    if (!panel) return;
    // 300 ms 淡出
    panel.classList.add('startup__panel--hiding');
    setTimeout(() => {
        panel.hidden = true;
        panel.classList.remove('startup__panel--hiding');
    }, 300);
}

app.jsmain() 最前面呼叫 initStartupPanel(),然後訂閱 4 個 eventcontrol-panel.md §5 的更新)。


7. 驗收條件

# 情境 預期
1 樂觀冷啟動(所有階段 ~1 s 4-5 s 內看到進度面板 6 階段逐一 completed → 淡出顯示主控台
2 日常啟動(非首次,~3 s 進度面板 min-display-time 1 s 後淡出
3 悲觀冷啟動Python wheels extract 慢) 8-10 s 內完成,階段 2 可能顯示較久但不觸發 retry hint
4 階段 2 卡 25 smock 測試) 看到「第 2 階段正在重試 …」副文字、但流程繼續
5 階段 3 失敗server binary 不存在) 階段 3 變 、收到 OS 通知、切 Error state
6 總時 > 60 smock 每階段等 12 s 60 s 時切 Error state + emit startup:error
7 AutoOpenBrowser=falseLinux 預設) 階段 5 立即 complete跳過 OpenInBrowser 實際呼叫)
8 WebSocket 30 s 內無 client 連上(罕見) 階段 6 失敗 → Error state
9 進度面板淡出不卡住主控台 300 ms ease 後主控台 UI 顯示,無視覺 artifact

8. Retry 機制RestartStartupSequencev2.1 新增)

觸發情境Wails 控制台進入 Startup Error state階段失敗或 60 s hard timeout使用者在 Error state 面板點「Retry」按鈕Design Spec v2.1 §3.7 定義)。這個行為不是 RestartServer()(後者只重啟 server 子程序,保留 port而是整個啟動流程重跑

8.1 BindingRestartStartupSequence

// visiona-local/app.go
// RestartStartupSequence resets the entire startup pipeline and tries again.
// Triggered by user clicking "Retry" button in Wails console Error state.
//
// 與 RestartServer 的差別:
//   RestartServer      : Stop → Start保留 port、沿用既有 StartupPipeline instance
//   RestartStartupSequence : ForceKill → 清狀態 → 重建 StartupPipeline → 從階段 2 跑(階段 1 直接 emit completed
func (a *App) RestartStartupSequence() error {
    // Step 1: 停止當前的 watcher goroutine避免舊 watcher 把剛重跑的階段誤判為 soft timeout
    if a.pipelineCancelFn != nil {
        a.pipelineCancelFn()
        a.pipelineCancelFn = nil
    }

    // Step 2: 強制殺掉 server 子程序(不等 graceful period我們是在 recover failure
    //         直接 ForceKill 比 Stop() 快Stop() 會走 7 s grace period 對這個情境沒必要
    if a.ctrl != nil {
        a.ctrl.ForceKill() // 新增 method內部 SIGKILL + 清 c.proc + setState(Stopped) 但不 emit crash notification
    }

    // Step 3: Reset state machine to Stopped避免 Error state 殘留)
    if a.ctrl != nil {
        a.ctrl.setState(ServerStateStopped, "")
    }

    // Step 4: 清 sentinel filecritical — 否則階段 6 會誤判為瞬間完成)
    sentinelPath := filepath.Join(a.dataDir, ".first-ws-connected")
    _ = os.Remove(sentinelPath)

    // Step 5: 重建 StartupPipeline 並呼叫 StartServer
    a.startupPipeline = NewStartupPipeline(a)

    // 階段 1「初始化 Wails 控制台」已經是 running 狀態(我們是 Wails app 本身,不需要重做),
    // 直接 emit completed 不重跑
    a.startupPipeline.mu.Lock()
    a.startupPipeline.startedAt = time.Now()
    a.startupPipeline.current = 1
    a.startupPipeline.stages[1].status = "completed"
    a.startupPipeline.stages[1].startedAt = time.Now()
    a.startupPipeline.stages[1].completedAt = time.Now()
    a.startupPipeline.mu.Unlock()
    a.startupPipeline.emitProgress(1) // emit stage=1 completed

    // 啟動 watcher goroutine
    watcherCtx, cancel := context.WithCancel(a.ctx)
    a.pipelineCancelFn = cancel
    a.startupPipeline.watcherDone = make(chan struct{})
    go a.startupPipeline.watcher(watcherCtx)

    // 切到階段 2 並真的跑
    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)

    // Step 6: 呼叫 StartServer內部會依序 Complete(2..6)
    //         Retry 情境允許 port fallback視同 cold start見 server-lifecycle.md §3.3
    return a.ctrl.Start()
}

8.2 前端整合

Wails 控制台的 Error state 面板顯示:

  • 錯誤訊息(來自 startup:error event 的 error 欄位)
  • 失敗階段(stage 欄位)
  • 「Retry」按鈕 → RestartStartupSequence().catch(showError)
  • 「Export log」按鈕 → ExportLog()(方便使用者回報問題時附 log
  • 「Quit」按鈕 → runtime.Quit(ctx)

點 Retry 後:

  1. 控制台 UI 切回 Starting state隱藏 Error 面板、顯示 Startup Progress 面板)
  2. 重跑 initStartupPanel() 將 6 個階段重新 render 為 pending階段 1 立即變 completed
  3. 訂閱者會陸續收到 startup:progress 事件更新 DOM

8.3 驗收

# 情境 預期
R1 階段 2 失敗 → 點 Retry → server 恢復可啟動 Retry 後階段 1 顯示 completed、階段 2-6 依序完成 → startup:ready
R2 階段 2 失敗 → 點 Retry → 仍然失敗Python binary 還是壞的) 再次進 Error state不會無限重試
R3 60 s hard timeout → 點 Retry 整個流程計時歸零,新一輪 60 s 上限
R4 Retry 時 port 3721 被其他程式佔用 允許 fallback 到 3722/3723…
R5 連點 Retry 兩次 第二次在 pipelineCancelFn != nil 檢查時安全地 cancel 舊 watcher不會殘留 goroutine
R6 階段 6 還在 pending 時點 Retry ForceKill 掉 server → sentinel file 被清 → 從階段 2 重跑

9. 待確認

  1. 階段 6 的「WebSocket 首次連線」實作方式(已定版) — v2.1 二次審閱定案:採用 sentinel file<dataDir>/.first-ws-connected)。詳見 §3 階段 6 章節。無需開發時再討論。
  2. 進度面板 min-display-time — 若啟動 < 1 s進度面板閃一下就消失不好看。建議設 1 s min-display-time即使 startup:ready 已 emit面板也要留 1 s 才淡出)。實作細節可放在 hideStartupPanel() 判斷 Date.now() - startedAt < 1000 時延遲執行
  3. R5-E5 文案 — Design Spec v2.1 尚未敲定i18n key 已預留。Design Agent 完成後可直接填入 i18n/*.json
  4. watcher goroutine 與 ctrl.Stop() 的交互 — 若使用者在啟動中間按了 Stop不太可能Starting 狀態下 action bar 禁用pipeline 要能 cancel。目前靠 stopWatcher() + ctrl.setState(Error) 處理,實測後若有 race 再補強
  5. ForceKill method 需新增到 ServerController — RestartStartupSequence 依賴這個 methodserver_control.go 要加一個非同步版 Stop直接 SIGKILL 不走 7 s grace、不發 crash notification。M8-4b 執行時補上