依 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>
720 lines
29 KiB
Markdown
720 lines
29 KiB
Markdown
# v2/startup-pipeline.md — R5-E 階段化啟動管線
|
||
|
||
> 所屬:TDD v2 §2.9(v2.1 新增)
|
||
> 版本:v2.1(2026-04-14 R5-E 實作細節)
|
||
> 決策依據:R5-E1 ~ R5-E6(AC-1.3 從 10 秒硬指標 → 60 秒 + 階段化進度)
|
||
> 對應 milestone:M8-4b(Backend Go + Frontend vanilla JS)
|
||
> 相關文件:`v2/control-panel.md` §3.1(啟動進度面板 UI)、`v2/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 連上 server(`OnClientConnected` 第一次觸發) |
|
||
|
||
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`(階段失敗)。
|
||
|
||
```go
|
||
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
|
||
- `skipped`(v2.1 新增):該階段依偏好設定或平台規則被跳過,不需執行也不檢查 timeout
|
||
- 目前使用情境:階段 5「開啟瀏覽器」在 `prefs.AutoOpenBrowser == false` 時(Linux 預設 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**)。僅作提示,不中斷流程。
|
||
|
||
```go
|
||
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。
|
||
|
||
```go
|
||
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` 後 emit,payload 為空。
|
||
|
||
```go
|
||
// 無 struct,EventsEmit(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` 命令 return);若 `AutoOpenBrowser=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 第一次觸發時:
|
||
```go
|
||
// 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 檔案:
|
||
```go
|
||
// 當 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 是兩個 process**:server 是由 `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`
|
||
|
||
```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.1:skip 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-one,`stages[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.go` 的 `startup()` 函式修改(示意):
|
||
|
||
```go
|
||
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 行)
|
||
|
||
```javascript
|
||
// 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.js` 在 `main()` 最前面呼叫 `initStartupPanel()`,然後訂閱 4 個 event(見 `control-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 s(mock 測試)| 看到「第 2 階段正在重試 …」副文字、但流程繼續 |
|
||
| 5 | 階段 3 失敗(server binary 不存在)| 階段 3 變 ❌、收到 OS 通知、切 Error state |
|
||
| 6 | 總時 > 60 s(mock 每階段等 12 s)| 60 s 時切 Error state + emit `startup:error` |
|
||
| 7 | `AutoOpenBrowser=false`(Linux 預設)| 階段 5 立即 complete(跳過 OpenInBrowser 實際呼叫)|
|
||
| 8 | WebSocket 30 s 內無 client 連上(罕見)| 階段 6 失敗 → Error state |
|
||
| 9 | 進度面板淡出不卡住主控台 | 300 ms ease 後主控台 UI 顯示,無視覺 artifact |
|
||
|
||
---
|
||
|
||
## 8. Retry 機制(RestartStartupSequence,v2.1 新增)
|
||
|
||
**觸發情境**:Wails 控制台進入 Startup Error state(階段失敗或 60 s hard timeout),使用者在 Error state 面板點「Retry」按鈕(Design Spec v2.1 §3.7 定義)。這個行為**不是** `RestartServer()`(後者只重啟 server 子程序,保留 port),而是**整個啟動流程重跑**。
|
||
|
||
### 8.1 Binding:`RestartStartupSequence`
|
||
|
||
```go
|
||
// 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 file(critical — 否則階段 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 依賴這個 method,`server_control.go` 要加一個非同步版 Stop:直接 SIGKILL 不走 7 s grace、不發 crash notification。M8-4b 執行時補上
|