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

230 lines
15 KiB
Markdown
Raw Permalink 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.

# Reviewer 審查 M8-4b 補丁2026-04-15
## 摘要
- 審查對象M8-4b 補丁3 Major + Stage 4 timeout 修復)
- 審查檔案:`visiona-local/startup_pipeline.go`L385-423 新增 helpers`visiona-local/server_control.go`L167-223 startInternal 守門、L630-652 probe timeout、L867-937 RestartStartupSequence + test hook`visiona-local/app.go`L109-119 struct、L1676-1693 openBrowser var`visiona-local/startup_pipeline_test.go`L393-660 4 個 regression test
- 總結論:**✅ 通過** — 3 Major 全部正確修復、Stage 4 timeout 改好、4 個 regression test 完整覆蓋且全綠。Build/Vet/Test/Racecount=2`visiona-local``server` 兩個 module 都 PASS。
- 3 Major 修復狀況M-1 ✅ / M-2 ✅ / M-3 ✅
- 問題統計Critical 0 / Major 0 / Minor 2 / Suggestion 1
- 阻擋 M8-10 嗎:**否**,可推進 M8-10。
---
## A. HasFailedStage helper 正確性
位置:`visiona-local/startup_pipeline.go` L385-404
- 邏輯正確 ✅:`current == -1``FailStage` 會把 current 設為 -1或迴圈檢查 `stages[1..6].status == "failed"`defensive path
- Lock 保護 ✅:`p.mu.Lock()` + defer `Unlock()``mu` 在 struct 是 `sync.Mutex`L98沒 RLock 可用 — 任務敘述提到的「RLock」不適用`Lock` 是正確選擇。效能影響可忽略(只在 startInternal error 路徑讀一次)。
- 文件註解清楚 ✅L386-391 解釋語義與對 M-1 的作用。
- Test 覆蓋 4 case ✅:`TestStartupPipeline_HasFailedStage`L537-566分別驗證new / running / FailStage 後current=-1/ 某 stage status=failed 但 current 未同步defensive
---
## B. IsInColdStart helper 正確性
位置:`visiona-local/startup_pipeline.go` L406-423
- 邏輯正確 ✅:`current >= 1 && current <= startupTotalStages`startupTotalStages=6stage 7ready與 current=-1failed、current=0未 Start都回 false。
- Lock 保護 ✅:同 `HasFailedStage`,用 `Lock/Unlock`
- Test 覆蓋邊界 ✅:`TestStartupPipeline_IsInColdStart`L578-606覆蓋 current ∈ {0, 1..6, 7, -1},迴圈確認 1..6 全 true其餘全 false。
---
## C. startInternal error 分支守門
位置:`visiona-local/server_control.go` L167-194
- 正確用 `pipeline.HasFailedStage()`L176-179
- `pipeline == nil` fallback ✅:`pipelineHandled` 預設 false → 走原 setState + emit + sendCrashNotification 邏輯。
- 非冷啟動路徑RestartServer 等pipeline 已 ready current=7若此刻 Start 失敗 HasFailedStage=falsefallback 正常發通知 ✅。
- 註解L168-175解釋完整說明為什麼守門 + 哪些路徑仍需 fallback。
- 不破壞 RestartServer`ctrl.Restart()``ctrl.Stop()` + `ctrl.Start()`)的錯誤處理 ✅RestartServer 發生時 pipeline.current==7 或 0`HasFailedStage()` 回 false。
**小提醒**(非 Major此守門建立在「pipeline FailStage 一定早於 startInternal return err」的假設上。實作上 `startServerV2` 每個 stage 失敗都立即 `FailStage`(見 server_control.go L437-448 / L554-566再 return err順序正確。若未來新增一條「不透過 FailStage 的錯誤返回路徑」,守門會被繞過。建議記為 Minor 追蹤(見 §L
---
## D. openBrowser 守門
位置:`visiona-local/server_control.go` L202-222
- `pipeline.IsInColdStart() == false` 才執行 R5-D3 openBrowser ✅L213-216
- RestartServer 路徑pipeline.current==7`IsInColdStart()` 回 false仍 openBrowser ✅。
- `pipeline == nil` 路徑 `inColdStart` 維持 falsefallback 仍 openBrowser ✅L214-216
- 註解L204-212清楚列出三種場景冷啟動 / RestartServer / RestartStartupSequence的預期行為可維護性高。
---
## E. openBrowser package-level var 改動
位置:`visiona-local/app.go` L1676-1693
- 改為 `var openBrowser = openBrowserExec`L1681。預設值是實作函式production 透過 var 呼叫只多一個 indirection效能影響可忽略。
- 所有呼叫端透過 var 呼叫:`app.go` L268runStartupStage5`server_control.go` L219startInternal R5-D3。Test 可替換 var 做 stub。
- `openBrowserExec` 保留為獨立函式L1684-1693作為預設實作macOS/Windows/Linux 三分支原封不動 ✅。
- Test 重置良好 ✅:`TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce``defer func() { openBrowser = original }()`L627保證 test 結束還原。
---
## F. RestartStartupSequence test 重構
位置:`visiona-local/startup_pipeline_test.go` L393-522
- **直接呼叫 `a.RestartStartupSequence()`** ✅L439— 不是手動複製 5 步驟。
-`restartStartFn` test hook 攔截 Step 6 ✅L424-433spy 記錄呼叫、檢查 sentinel 是否已清、snapshot 新 pipeline。
- `callLog` 驗證順序 ✅L497-516`pipelineCancel` 必須在 `startFn` 之前。
- 驗證新 pipeline 建立 ✅L466-486stage1=completed / stage2=running / current=2。
- `pipelineCancelFn``context.CancelFunc` 型別允許 assign 任意 `func()`L407-410
- 其他 side effect 驗證完整proc 被清為 nilL453-458 ForceKill 效果、sentinel file 已刪除L461-463、啟動前 ctrl state 設為 Error 模擬 Retry 前置條件L418
- 「若未來把 Step 4 移到 Step 2 之前」的概念驗證test 雖無法直接抓 Step 4 ↔ Step 2 相對順序但能抓「Step 4 在 Step 6 之前」(`sentinelClearedBeforeStart` L489-491滿足原 Reviewer 對 M-3 的核心期待(驗證真的呼叫 method、不是手動複製
**Minor F-1**(見 §L`newPipelineTestApp``a.ctx == nil`,所以 RestartStartupSequence Step 5 的 watcher goroutine 分支不會啟動L518-521 的「清理新啟動的 watcher」註解有點誤導實際上 pipelineCancelFn 已在 Step 1 被清為 nil之後也沒有被重設。無功能影響純註解細節。
---
## G. TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce
位置:`visiona-local/startup_pipeline_test.go` L615-660
- 正確 stub `openBrowser` var ✅L621-627defer 還原。
- `AutoOpenBrowser=true`L618確保 runStartupStage5 會走 openBrowser 分支(不 skip
- `restartStartFn` 內模擬 server 啟動成功:設 fake proc port=12345、setState(Running)、手動推進 pipeline stages 到 stage 5 running ✅L630-645。這讓 `runStartupStage5` 能從 `ctrl.proc.port` 取到 URL 並呼叫 openBrowser。
- 斷言 `openCount == 1`L652-654錯誤訊息清楚標示 want 1。
- 驗證冷啟動路徑下「startInternal 的 R5-D3 open」不會發生 — 因為 restartStartFn 替換了 `ctrl.Start()`,完全 bypass startInternal所以實際上這個 test 驗證的是 runStartupStage5 本身只會 open 一次。
**Minor G-1**(見 §L此 test 沒有實際經過 startInternal 的 R5-D3 分支restartStartFn 完全替換 ctrl.Start所以嚴格來說它驗證的是「runStartupStage5 本身的 open 次數」,而不是「冷啟動中 startInternal + runStartupStage5 的 open 次數總和」。要完整驗證 M-2需要一個能跑 startInternal 的整合 test但 startInternal 會 spawn python server 無法 unit test。現況可接受因為 IsInColdStart 邏輯已被 B 節 `TestStartupPipeline_IsInColdStart` 單獨驗證startInternal 的守門條件 `!inColdStart` 是純布林邏輯,靜態即可推得正確。
---
## H. Stage 4 probe timeout 2s
位置:`visiona-local/server_control.go` L630-652
- 註解說明原因 ✅L634-636 解釋 TDD §3「秒回即算完成」+「2s 足以涵蓋正常 latency」。
- 不破壞既有 test ✅Stage 4 相關 test 全綠(`go test -count=1` PASS
- 逾時仍算完成 ✅L647-651 不論 err 或 status 都 CompleteStage(4)),符合 TDD §3 設計意圖。
---
## I. Build/Test/Race 結果
| 指令 | 結果 |
|------|------|
| `cd visiona-local && go build .` | PASS無輸出 |
| `cd visiona-local && go vet ./...` | PASS無輸出 |
| `cd visiona-local && go test -count=1 ./...` | PASS8.210s |
| `cd visiona-local && go test -race -count=2 ./...` | **PASS16.225s** |
| `cd server && go build ./...` | PASS |
| `cd server && go vet ./...` | PASS |
| `cd server && go test -count=1 ./...` | PASSapi/handlers/ws/device/model 全通過) |
| `cd server && go test -race ./...` | PASS |
4 個新 test 單獨跑 verbose
```
--- PASS: TestStartupPipeline_RestartStartupSequence_StepsExecution (0.00s)
--- PASS: TestStartupPipeline_HasFailedStage (0.00s)
--- PASS: TestStartupPipeline_IsInColdStart (0.00s)
--- PASS: TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce (0.00s)
```
---
## J. 合併檢查
`git diff --stat` 看過全部變更,無衝突標記、無重複區塊。合併狀況:
- **M8-4 原版** + **M8-4 補丁**Stop/ForceKill/handleWatchFailure/logPump`ForceKill` L273-291 的 cancelWatcher 順序修復仍在,`RestartStartupSequence` Step 2 正確利用 ✅。
- **M8-4b 原版**startServerV2 hook + probe + RestartStartupSequence補丁新增 helpers 與守門邏輯未動到既有 pipeline struct 欄位與 stage hook 位置 ✅。
- **MAJ-4 補丁**shutdown-imminent broadcast + notifyShutdownImminent不觸及 startup pipeline / server_control 的 startInternal / RestartStartupSequence 區塊 ✅。
- `grep` 未見 `<<<<<<<` / `>>>>>>>` 合併標記。
---
## K. 3 Major 症狀驗證
### M-1重複 OS 通知)
症狀:冷啟動 stage 3 失敗 → `emitError` 發通知 A → startInternal error 分支再發通知 B。
修復後的流程:
1. `startServerV2` 失敗 → `pipeline.FailStage(3, err)``emitError` 發通知「第 3 階段失敗」+ setState(Error) + current = -1
2. startInternal 收 err → `HasFailedStage()` 回 true → `pipelineHandled = true`**skip** setState/emit/sendCrashNotification
3. 只剩一個通知 ✅
**症狀消失確認**Major M-1 修復路徑正確唯一前提是「startServerV2 的所有失敗路徑都走 FailStage」— 已確認 L437-448stage 2、L554-566stage 3、stage 4 只在 timeout 仍 complete 不 fail符合條件。
### M-2重複開瀏覽器
症狀:冷啟動成功 → runStartupStage5 的 openBrowser 呼叫 1 次 + startInternal R5-D3 的 openBrowser 呼叫 1 次 = 2 次。
修復後的流程:
1. 冷啟動 → startServerV2 成功 → startInternal 檢查 `IsInColdStart()`(此時 current ∈ 1..6)→ 回 true → **skip** R5-D3 openBrowser
2. startServerV2 返回後app.startup 呼叫 `runStartupStage5` → openBrowser 1 次 ✅
3. RestartServer 路徑pipeline.current==7 → IsInColdStart 回 false → R5-D3 正常 open ✅
**症狀消失確認**:冷啟動 openBrowser 只被呼叫一次。`TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce` 驗證 openCount==1。
### M-3Retry test 重構)
症狀test 手動複製 Step 1-5改步驟順序抓不到。
修復後:直接呼叫 `a.RestartStartupSequence()` + callLog spy。若未來調整步驟順序cancel 晚於 startcallLog 順序斷言會 fail ✅;若把 sentinel 清除改到 start 之後Step 4 → Step 6 之後),`sentinelClearedBeforeStart` 斷言會 fail ✅。
**概念驗證**test 能抓到「cancel 必須早於 start」與「sentinel 必須在 start 前清」兩個順序不變量,涵蓋 M-3 的核心意圖。
---
## L. 問題清單
### Critical
(無)
### Major
(無)
### Minor
| # | 檔案 | 行 | 問題 | 建議 |
|---|------|----|------|------|
| m-1 | `visiona-local/startup_pipeline_test.go` | L518-521 | 註解說「清理新啟動的 watcher goroutine」`newPipelineTestApp``a.ctx == nil`Step 5 的 watcher 分支不會啟動pipelineCancelFn 在 Step 1 已被清為 nil。註解誤導但無功能影響 | 改註解為「若 ctx 存在則清理重建的 watcher此 test 走 nil ctx 分支,為 safety」 |
| m-2 | `visiona-local/server_control.go` | L167-194 | `pipelineHandled` 守門建立在「startServerV2 所有失敗路徑都走 FailStage」的隱性假設上若未來新增一條不透過 FailStage return err 的路徑M-1 症狀會復活 | 在 `startServerV2` 函式開頭加註解「所有 err return 前必須先 FailStage」或改用 explicit flag`a.ctrl.suppressFallbackError`)由 `emitError`startInternal 讀後 clear |
### Suggestion
| # | 內容 |
|---|------|
| s-1 | `TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce` 嚴格來說驗證的是 runStartupStage5 本身只 open 一次(因為 restartStartFn 替換了 ctrl.Start完全 bypass 了 startInternal 的 R5-D3 分支。M-2 的守門邏輯(`IsInColdStart() == false` 才 open是靠 `TestStartupPipeline_IsInColdStart` 單獨驗證的 pure boolean 邏輯。兩個 test 合起來足以推得正確性,但若希望更貼近「真實 M-2 症狀」,可另加一個 test 透過 `a.ctrl.startInternal` 直接呼叫(需要先在 ctrl 加 test-only hook 允許跳過 spawn非必要 |
---
## M. 結論
**判定:✅ 通過**
補丁對 M8-4b 原版 Reviewer 提出的 3 Major 全部正確修復:
1. **M-1重複通知** — 新增 `HasFailedStage()` helper + `startInternal` error 分支守門邏輯正確、lock 保護得當regression test`TestStartupPipeline_HasFailedStage`)覆蓋 4 case 全通過。
2. **M-2重複開瀏覽器** — 新增 `IsInColdStart()` helper + `startInternal` R5-D3 守門 + `openBrowser` 改 package-level var 以便 stubregression test`TestStartupPipeline_IsInColdStart` + `TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce`)驗證完整。
3. **M-3Retry test 重構)** — 新增 `restartStartFn` test hook + 重構 `TestStartupPipeline_RestartStartupSequence_StepsExecution` 為直接呼叫 method + callLog spy 驗證順序,抓得到 cancel↔start 與 sentinel↔start 的順序不變量。
**Stage 4 probe timeout** 從 5s 改為 2s註解說明符合 TDD 原意。
**工程品質**
- Build / Vet / Test / Race-count=2在 visiona-local 與 server 兩個 module 全綠
- 合併乾淨,無衝突標記
- 註解詳盡(每個 helper 都有中文註解解釋「為什麼」與「哪些場景走 fallback」
- Lock 使用正確(`sync.Mutex` + `Lock/Unlock`,沒有 race condition
- Test hook 透過 struct field 注入,不污染正式路徑(`restartStartFn` 預設 nil
**不阻擋 M8-10**。Minor 2 項 + Suggestion 1 項可記為技術債,不強制當下處理。
**優點**
- Helper 命名清楚(`HasFailedStage` / `IsInColdStart` 語義一眼就懂)
- 守門邏輯對 `pipeline == nil` fallback 處理完整,不會因單測環境或非冷啟動路徑被誤傷
- 4 個 regression test 覆蓋面完整helper 本身 + 實際 RestartStartupSequence 流程 + openBrowser 呼叫次數
- 透過改 `openBrowser` 為 package-level var 解決測試 stub 問題,是 Go idiomatic 的做法
- `restartStartFn` test hook 設計乾淨:預設 nil → 走正式 `a.ctrl.Start()`,單測替換即可,不會意外漏到 production
- 2s probe timeout 的註解說明了「為什麼不是 5s」— 文件化設計決策