依 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>
155 lines
12 KiB
Markdown
155 lines
12 KiB
Markdown
# Reviewer 審查 M8-9 Boot-ID + tab 重連(2026-04-15)
|
||
|
||
## 摘要
|
||
- 結論:**✅ 通過**,無 Critical / Major 問題。
|
||
- 是否阻擋 M8-10:**不阻擋**,可直接進入 M8-10。
|
||
- 問題統計:Critical 0 / Major 0 / Minor 2 / Suggestion 1
|
||
- 親跑:`tsc` PASS、`test` 19/19 PASS、`lint`(改動檔零 error)、`build` 12 頁 PASS。
|
||
|
||
---
|
||
|
||
## A. `checkAndUpdateBootId` action
|
||
比對 TDD §9.3 規格(`system-store.ts:92-103`):
|
||
|
||
| 行為 | 實作 | 狀態 |
|
||
|------|------|------|
|
||
| `bootId === null` → 記錄 + 回 `'first'` | L94-97 `set({ bootId: newBootId })` → return `'first'` | ✅ |
|
||
| `bootId === newBootId` → 回 `'match'`,不改 store | L98-100 純 return | ✅ |
|
||
| `bootId !== newBootId` → 回 `'mismatch'`,**刻意不改 store** | L101-102 註解明確說明用意(reload 後新 tab 重走 first) | ✅ |
|
||
| TypeScript `BootIdCheckResult` union 定義 | L18-26 `'first' \| 'match' \| 'mismatch'` + JSDoc 完整 | ✅ |
|
||
|
||
語意正確,註解與 TDD「mismatch 時不更新 store」的理由一致。`get()` 而非閉包 snapshot,避免競態。
|
||
|
||
## B. `handleBootIdCheck` helper
|
||
`use-shutdown-watcher.ts:87-123`:
|
||
|
||
- **封裝完整**:呼叫 store action、處理 loop guard、觸發 reload 全部集中於此 helper。
|
||
- **DRY**:`pollOnce`(L146)與 `retryServerHealth`(L347)都走同一 helper,沒有重複邏輯。
|
||
- **Response shape 正確**:`BootIdResponse` type(L46-52)完整對應 TDD §9.2 `{ success, data: { bootId, startedAt } }`。L140 / L342 對 `json.success` 與 `json.data?.bootId` 雙重驗證,若缺欄位改走 `recordFailure`,不會把 undefined 丟進比對路徑。
|
||
- **回傳語意**:`true` = 繼續後續流程(first / match),`false` = 已觸發 reload(或 SSR / guard skip),呼叫端不再 `markOnline`。L90-92 的 early return 避免對「即將 reload」的 tab 做多餘的 markOnline。
|
||
|
||
## C. Reload loop guard
|
||
`use-shutdown-watcher.ts:87-123`:
|
||
|
||
| 檢查項 | 實作 | 狀態 |
|
||
|--------|------|------|
|
||
| key = `visiona-reload-loop-guard` | L38 `const RELOAD_LOOP_GUARD_KEY` | ✅ |
|
||
| 正常情境:reload → store reset → 走 first 路徑不誤觸發 | L89 `checkAndUpdateBootId` 先於 guard 比對;first 直接 return true,guard 不會被讀到 | ✅ |
|
||
| 異常情境(server 每次回不同 bootId):guard 命中同一 id → skip reload + `setBootId + markOnline` | L102-111 | ✅ |
|
||
| sessionStorage 失敗(隱私模式)→ 仍 reload | L100-115 整段包 `try { ... } catch {}`,catch 為空但之後 fall through 到 L117-121 `window.location.reload()` | ✅ |
|
||
| guard key 寫入時機:**reload 之前** | L112 `setItem` → L121 `reload()`,順序正確 | ✅ |
|
||
| `typeof window === 'undefined'` → SSR 保險直接 return false | L95-98 | ✅ |
|
||
|
||
**Minor 1**:catch 空 block(L113-115)沒有任何 log,若 sessionStorage 存取失敗(即便在隱私視窗)開發者將看不到訊號。建議加 `console.warn('[boot-id] sessionStorage unavailable, reload guard disabled')`,只是提醒用,不阻擋 reload。不影響行為,不阻擋合併。
|
||
|
||
## D. 和 M8-7 的 integration
|
||
- `pollOnce` (L126-152):保留原流程,新增路徑僅是在 `res.ok + json.success + data.bootId` 全部成立後插入 `handleBootIdCheck`;失敗分支完全沿用 M8-7 的 `recordFailure`,polling → overlay 行為不變。
|
||
- WebSocket `server:shutdown-imminent`(L225-249)、restart 10 s defer(L232-242)、visibility change probe(L279-292)、cleanup(L303-323)全部不動。
|
||
- Active retry 3 s / normal 10 s 切換邏輯(L186-201)不動,boot-id 比對只發生在「成功取得 response」那條路徑,不影響 retry 節奏。
|
||
- `retryServerHealth`(L328-358,M8-7 既有函式)被 patch 成走 `handleBootIdCheck`,對 overlay 的「手動 retry 按鈕」呼叫者透明。
|
||
|
||
既有測試(M8-7、model-store、lib/api)全數通過 → 零回歸。
|
||
|
||
## E. Test 品質
|
||
|
||
### Store tests(`system-store.test.ts`)— 4 個
|
||
1. `first` call 情境 → 驗 return + `bootId` 被設(L24-28)✅
|
||
2. 相同 bootId → 驗 `'match'` + store 不動(L30-35)✅
|
||
3. 不同 bootId → 驗 `'mismatch'` + store 保留舊值(L37-43)✅
|
||
4. Reset 後再呼叫 → 重新 `'first'`,模擬 reload 後情境(L45-52)✅
|
||
|
||
TDD §12 要求的三路徑 + 邊界全數覆蓋。beforeEach(L14-22)確實 reset 所有欄位,隔離性 OK。
|
||
|
||
### Hook tests(`use-shutdown-watcher.test.ts`)— 5 個
|
||
1. 首次 fetch → setBootId + markOnline,無 reload(L65-74)✅
|
||
2. 相同 id fetch → markOnline,無 reload(L76-85)✅
|
||
3. 異 id fetch → `window.location.reload` 被呼叫 + guard 寫入 + store 不更新(L87-99)✅
|
||
4. Loop guard 命中 → 不 reload + store 對齊新 id + `markOnline`(L101-113)✅
|
||
5. Fetch 失敗 → `recordFailure` + 無 reload(L115-124)✅
|
||
|
||
`mockFetchBootId` helper 乾淨,`vi.stubGlobal('fetch', ...)` 正確。`Object.defineProperty(window, 'location', ...)` 是 jsdom 下替換 `location.reload` 的標準做法(jsdom 的 `window.location` 不可直接賦值)。`afterEach` L61-63 `vi.restoreAllMocks()` 搭配 beforeEach 清除 `sessionStorage` + 重置 store,測試間無耦合。
|
||
|
||
**Suggestion**:SSR 情境(`typeof window === 'undefined'`)走 false path(L95-98),目前 hook test 不覆蓋。因為 jsdom 下必定有 window,要另寫 node env test。優先級低,M8-9 可接受不補。
|
||
|
||
**Minor 2**:task brief 提到「sessionStorage 失敗(私密視窗):catch 後仍 reload」的邊界 case,但 hook test 5 個中沒有直接模擬 `sessionStorage.setItem` throw 的情境。實作正確(L100-115 catch fall through 到 reload),但無 regression guard。建議補一個 test:`sessionStorage.setItem` 用 `vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error(); })` → 驗 `reloadSpy` 仍被呼叫。非阻擋性,可列入 M8-10 之後的測試補強清單。
|
||
|
||
## F. Build / Test / Lint 結果
|
||
|
||
| 指令 | 結果 |
|
||
|------|------|
|
||
| `pnpm tsc --noEmit` | ✅ 零錯誤(無任何輸出) |
|
||
| `pnpm test` | ✅ 4 test files / 19 tests 全綠(含本次新增 4+5) |
|
||
| `pnpm lint`(全 repo) | ⚠️ 16 問題全部為 **pre-existing**(`use-server-health.ts`、`use-websocket.ts` 等檔),**M8-9 改動的 4 個檔零 error / 零 warning** |
|
||
| `pnpm build` | ✅ Compiled successfully 5.5s / 12 頁 SSG 全數 prerender 完成 |
|
||
|
||
既有 repo lint 問題不在本次審查範疇,不阻擋 M8-9。
|
||
|
||
## G. SSR 相容
|
||
- `'use client'` directive 在 L1 正確標記。
|
||
- `window`、`sessionStorage`、`window.location.reload` 全部在 `useEffect`(L155)或 `handleBootIdCheck` 函式內使用,且 L95-98 有 `typeof window === 'undefined'` guard(雙重保險,因為 hook 只會在 client 執行但 `retryServerHealth` 可能被 server component 誤呼)。
|
||
- `retryServerHealth` 走同一 `handleBootIdCheck`,SSR guard 也保護它。
|
||
- Next.js `pnpm build` 的 SSG prerender 12 頁全部成功,證實 build 階段不會觸發 `window is not defined`。
|
||
|
||
## H. 完整 flow 驗證(讀 code 推論)
|
||
|
||
### 場景 1:Restart Server
|
||
1. Wails `notifyShutdownImminent(reason=restart)` → WS broadcast。
|
||
2. Browser tab 收到(`use-shutdown-watcher.ts:225-249`)→ `restartDeferTimer` 啟動 10 s defer(L234-241),**不立刻** `forceOffline`。
|
||
3. 每 10 s(normal)/ 3 s(active-retry)poll `/api/system/boot-id`。Restart 期間 server 約 3 s 起步,fetch 連續 `ECONNREFUSED` → `recordFailure` 累計 → consecutiveFailures ≥ 2 → subscribe callback(L186-201)自動切 active-retry。
|
||
4. Server re-spawn → 新 boot-id。
|
||
5. 下一次 `pollOnce` → `res.ok + json.success + data.bootId` → `handleBootIdCheck`:store 裡舊 bootId ≠ 新 bootId → mismatch → guard 寫入 → `window.location.reload()`。
|
||
6. 新 tab 載入 → Zustand store reset `bootId = null` → 首次 poll → `'first'` → `setBootId` + `markOnline` → 正常運作。
|
||
|
||
路徑正確 ✅
|
||
|
||
### 場景 2:Quit
|
||
1. Wails `notifyShutdownImminent(reason=quit)` → WS broadcast。
|
||
2. Browser tab 收到 → `mapReason('quit') → 'quit'` → L245 `forceOffline('quit')` 立即執行(**沒有 10 s defer**,defer 只在 `reason === 'restart'` 分支,L232)。
|
||
3. Polling 持續失敗(server 整組關掉)→ overlay 顯示「請重開 app」。
|
||
4. 每次 poll fail → `recordFailure`,**不進入** `handleBootIdCheck`(整個 L146 block 要 `res.ok + success + data.bootId` 才會進)。
|
||
5. Server 沒回來,`reloadSpy` 永不會被呼叫。
|
||
|
||
路徑正確,不會誤觸發 reload ✅
|
||
|
||
### 場景 3:網路 glitch
|
||
1. 單次 health check fail → `recordFailure`,consecutiveFailures = 1,尚未 ≥ 2 門檻 → **不** forceOffline、**不** 切模式。
|
||
2. 第二次失敗 → consecutiveFailures = 2 → subscribe callback 補上 `forceOffline('healthcheck-failed')`(L191-197)→ 切 active retry。
|
||
3. Active retry 3 s 後 → server 還在 → 拿到**同**一 boot-id → `handleBootIdCheck` 回 `'match'`(因為 store.bootId 沒被任何人改)→ L147 的 `if (!shouldContinue)` false → 往下到 L148 `markOnline()`(會同時 reset consecutiveFailures=0 + forcedOffline=false + offlineReason=null)→ subscribe callback 偵測到 shouldActiveRetry=false,下一次 polling tick 自動切回 normal 10 s。
|
||
4. `reloadSpy` 不會被呼叫。
|
||
|
||
路徑正確,不會誤 reload ✅
|
||
|
||
**額外確認**:Restart 路徑下,如果 boot-id 變化先於 restartDeferTimer 10 s 到期 → `handleBootIdCheck` 會直接 reload,restartDeferTimer 還在 running。`useEffect` cleanup(L312-315)會 clear timer;但 reload 觸發後頁面立刻卸載,timer 會被 jsdom / 瀏覽器自然清掉,無副作用。若 reload 被 SSR / guard skip(極端情境),restartDeferTimer 仍會 10 s 後執行,此時 `forceOffline('restart')` 的 guard 檢查 `!s.serverOnline || consecutiveFailures >= FAILURE_THRESHOLD`(L237)會判定為 false(guard skip 路徑裡 markOnline 已被呼叫)→ 不會誤顯示 overlay。健壯。
|
||
|
||
## I. 問題清單
|
||
|
||
### Critical
|
||
無。
|
||
|
||
### Major
|
||
無。
|
||
|
||
### Minor
|
||
| # | 檔案 | 行 | 問題 | 建議 |
|
||
|---|------|----|------|------|
|
||
| 1 | `frontend/src/hooks/use-shutdown-watcher.ts` | 113-115 | sessionStorage try/catch 吞 error 無 log,異常發生時無法從 console 察覺 | catch 內加 `console.warn('[boot-id] sessionStorage unavailable, reload guard disabled')`,不改 control flow |
|
||
| 2 | `frontend/src/tests/hooks/use-shutdown-watcher.test.ts` | — | 無 test 覆蓋「sessionStorage.setItem throw → 仍 reload」邊界 | 用 `vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error(); })` 補 1 個 test,驗 `reloadSpy` 仍呼叫 1 次 |
|
||
|
||
### Suggestion
|
||
| # | 檔案 | 行 | 建議 |
|
||
|---|------|----|------|
|
||
| 1 | `frontend/src/hooks/use-shutdown-watcher.ts` | 95-98 | SSR guard 分支(`typeof window === 'undefined'`)無對應 test;jsdom 下難模擬。可延後到專門的 SSR 測試環境補 |
|
||
|
||
## J. 結論
|
||
|
||
M8-9 Boot-ID + tab 自動重連實作完整、流程正確、測試充分。
|
||
|
||
- Store action `checkAndUpdateBootId` 三路徑語意與 TDD §9.3 一致,`mismatch` 刻意不改 store 的設計與 reload-後-新-tab 重走 first 路徑完美配合。
|
||
- Helper `handleBootIdCheck` DRY 封裝 guard + reload,`pollOnce` / `retryServerHealth` 共用同一路徑。
|
||
- Reload loop guard(sessionStorage)在正常 / 異常 / 隱私視窗三種情境下都有正確 fall back,不會卡 loop。
|
||
- M8-7 既有邏輯(polling 模式切換、WebSocket、visibility、restart defer)全數不變,19 個 tests 全綠零回歸。
|
||
- 三個關鍵場景(Restart / Quit / glitch)讀 code 推論路徑全部正確,特別是 Quit 不會誤觸發 reload(`handleBootIdCheck` 進入條件嚴格)、glitch 同 bootId 會乾淨 recover。
|
||
- `tsc` / `build` / 改動檔 `lint` 全數 PASS;pre-existing lint 問題不在本次範疇。
|
||
|
||
**結論:✅ 通過,不阻擋 M8-10**。2 個 Minor 與 1 個 Suggestion 可視為技術債,建議列入 M8-10 之後的測試補強清單處理,非必修。
|