fix(local-tool): 拆 built-in / user data dir — 修預設模型永遠載入 0 個

M8-10a smoke test 抓到 M1 就潛藏的 P0 latent bug:server 預設 dataDir =
<binary>/data,bundle 內解析成 Contents/Resources/bin/data/(空目錄),
實際 models.json + 8 個 .nef 住在 Contents/Resources/data/(上一層)。
Wails 又傳 --data-dir 成 user home(writable),同樣沒 models.json。
結果任何正式啟動路徑下 /api/models 都回 total: 0,M1-M7 smoke 從沒
跑過這個 endpoint 才漏抓。

修法:把「read-only bundle 內資料」和「writable user 資料」語意拆開。

- 新增 findFirstExisting(candidates, sentinel) helper
- 新增 resolveBuiltInDataDir:①VISIONA_BUNDLE_LIB_DIR/data(AppImage)
  ②<base>/data ③<base>/../data ④<base>/../Resources/data
  ⑤<base>/../lib/visiona-local/data(Linux FHS)。命中條件是
  models.json 存在為 regular file,避開 Wails build artifact 留下的
  空 data/ 目錄
- main() 拆 builtInDataDir(modelRepo + flash.Service 用)與 dataDir
  (custom-models / sentinel / logs 用),職責分明
- flash.NewService 改吃 builtInDataDir — 它要解析 models.json 裡的
  相對路徑 "data/nef/kl520/xxx.nef",來源是 bundle 不是 user home
- resolveBridgeScript 同步修(同樣的技術債一起清),候選 env var 優先
  + FHS fallback,避免 Linux AppImage 上 kneron_bridge.py 也找不到
- fallback 全 filepath.Abs 化,log.Printf 印嘗試過的路徑清單便於除錯

驗證(build / vet / test + smoke)全綠:
- macOS bundle:/api/models → 15 models 
- dev mode(server/ 下 go run):15 models 
- Linux AppImage 模擬 + env var:命中候選 1 
- Linux AppImage 模擬 + 無 env var:命中候選 5(FHS)
- 全不命中:log 印完整 tried 清單 + server 不 crash 

Reviewer 兩輪通過。第一輪抓到 Linux AppImage 未覆蓋(Major-1)+ 2 Minor
+ 2 Suggestion;第二輪確認全部處理到位、新發現兩項非阻擋風格建議列為
技術債。報告見 .autoflow/05-implementation/reviews/review-m8-10a-*.md。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-04-15 20:23:25 +08:00
parent 8cd5751ce3
commit d7cddf364b
3 changed files with 409 additions and 28 deletions

View File

@ -0,0 +1,203 @@
# Code Review 報告 — M8-10a P0 Latent Bug 修復built-in data dir 解析)
## 審查摘要
- **審查對象**`server/main.go`(新增 `resolveBuiltInDataDir` helper + `main()` 拆分 `builtInDataDir` / `dataDir` 兩個變數)
- **關聯檔**`server/internal/flash/service.go`(接收參數由 `dataDir``builtInDataDir`,無 body 變動)
- **產出 Agent**Backend
- **審查結果**:⚠️ **需修 1 個 MajorLinux AppImage 布局未覆蓋)後通過**
- **問題統計**Critical: 0 / Major: 1 / Minor: 2 / Suggestion: 2
---
## 獨立驗證結果
| 驗證項 | 指令 | 結果 |
|-------|------|------|
| Build | `cd server && go build -o /tmp/rv-server .` | ✅ PASS |
| Vet | `cd server && go vet ./...` | ✅ PASS |
| Test | `cd server && go test -count=1 ./...` | ✅ 全綠api / api/handlers / api/ws / device / model 5 個有測試的 package 全部通過) |
| Smoke (bundle) | 直接跑 `.../Contents/Resources/bin/visiona-local-server -port 3801 -data-dir $(mktemp -d)` | ✅ `Built-in data dir: .../Contents/Resources/data``Loaded 15 built-in models``GET /api/models` 回 15 個 model`kl520-yolov5-detection``filePath` = `data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef`bundle 實體 .nef 檔存在7,506,224 bytes與 catalog `modelSize: 7200000` 為人工估值合理吻合) |
| Smoke (dev mode) | 把 binary 放到 `server/` 下跑 `./rv-server-tmp -port 3802 -data-dir $(mktemp -d)` | ✅ `Built-in data dir: /Users/jimchen/visionA/local-tool/server/data``Loaded 15 built-in models``GET /api/models` 回 15 個 model |
**修復前的 bug 無法在修復後重現**:不管 Wails 傳什麼 `--data-dir``builtInDataDir` 都從 bundle 內解析,`/api/models` 保證回 > 0 個 model。
---
## 所有候選布局覆蓋狀態
| 布局 | binary 位置 | data 位置 | 命中候選 | 覆蓋狀態 |
|------|-----------|-----------|---------|---------|
| **macOS bundle** | `Contents/Resources/bin/visiona-local-server` | `Contents/Resources/data/` | `<base>/../data` | ✅ 已驗證 |
| **Dev modego run / 直接在 server/ 下)** | `server/visiona-local-server`(或 cwd | `server/data/` | `<base>/data` | ✅ 已驗證 |
| **Windows installer**Inno Setup`installer/windows/visiona-local.iss` L72/L86| `{app}\bin\visiona-local-server.exe` | `{app}\data\` | `<base>/../data` | ✅ 理論命中(未在 Windows 機器實跑,但 Inno 布局 = `base = {app}\bin``<base>/../data = {app}\data`,路徑對齊) |
| **Linux AppImage**`installer/linux/build-appimage.sh` L29-33, L80| `$APPDIR/usr/bin/visiona-local-server` | `$APPDIR/usr/lib/visiona-local/data/` | **無**(三個候選算出來分別是 `/usr/bin/data``/usr/data``/usr/Resources/data`,沒一個對) | ❌ **不覆蓋 — 見 Major-1** |
---
## 文件符合性檢查
這次是 P0 bug fix既有行為修復依 Autoflow S 級規則不需新增 PRD/TDD 條目。修復與現有 TDD §架構決策bundle 內唯讀 vs user home 可寫分離)一致——事實上修復才讓程式碼行為與 TDD 的決策真正對齊(修復前是文件對但程式碼錯)。
---
## 問題清單
### Critical必須修復阻擋交付
無。
### Major必須修復但不阻擋 macOS 初步交付)
| # | 檔案 | 行數 | 問題描述 | 建議修改方式 |
|---|------|------|----------|-------------|
| M-1 | `server/main.go` | 95-112 | `resolveBuiltInDataDir` 的三個候選沒有覆蓋 Linux AppImage 布局。AppImage 把 server binary 放 `usr/bin/`、data 放 `usr/lib/visiona-local/data/`(見 `installer/linux/build-appimage.sh` L29-33, L80三個現有候選 `<base>/data``<base>/../data``<base>/../Resources/data` 都不會命中。結果Linux 版 AppImage 啟動後 built-in catalog 依然載入 0 個 model等於這個 P0 bug 在 Linux 上**沒被修掉**。另外 `AppRun` 已經 export `VISIONA_BUNDLE_LIB_DIR=${HERE}/usr/lib/visiona-local`L134server 目前完全沒讀這個 env var。**備註**:同樣的缺失其實也存在於先前就有的 `resolveBridgeScript`L60-78不是本次 PR 引入的 regression但應同時修掉。 | 建議兩層保險同時做:①在 `resolveBuiltInDataDir` 開頭優先讀 `VISIONA_BUNDLE_LIB_DIR` env若非空且 `<env>/data/models.json` 存在則直接回 `<env>/data`);②新增第 4 個候選 `<base>/../lib/visiona-local/data`(對齊 FHS 布局,未來其他 Linux 打包方式也能用)。`resolveBridgeScript` 同步比照修掉,避免 Linux 版 Kneron bridge 也找不到。 |
### Minor建議修復
| # | 檔案 | 行數 | 問題描述 | 建議修改方式 |
|---|------|------|----------|-------------|
| m-1 | `server/main.go` | 110-111 | 三個候選全部沒命中時回的 fallback 是 `filepath.Join(base, "data")`,不是 `filepath.Abs(...)` 後的絕對路徑,與成功路徑回的是絕對路徑不一致(成功分支有 `filepath.Abs(c)`)。雖然 `model.NewRepository` 還是能用相對路徑讀檔,但 log 裡印出來的 `Built-in data dir: ...` 會是相對路徑,讓除錯變難。 | fallback 也過一次 `filepath.Abs``if abs, err := filepath.Abs(filepath.Join(base, "data")); err == nil { return abs }; return filepath.Join(base, "data")`。 |
| m-2 | `server/main.go` | 95-112 | 函式沒 log 自己試了哪幾條路徑。落到 fallback 時 `NewRepository` 只會印 `Warning: could not load models from <path>`使用者看不到「我還試了另外兩個位置都沒中」。bug reporting 體驗不佳。 | 在 fallback 分支 `return` 前用 `log.Printf`(或注入 logger印出嘗試過的三個候選路徑或在函式簽名改成接收 logger。 |
### Suggestion非必要改善建議
| # | 檔案 | 行數 | 建議內容 |
|---|------|------|----------|
| s-1 | `server/main.go` | 95-112 | `resolveBuiltInDataDir``resolveBridgeScript`L60-78兩個函式結構幾乎一模一樣都是「一組候選 → 找存在的 → fallback 回第一個」)。可以抽一個通用 `findFirstExisting(candidates []string, sentinel string) (string, bool)`,讓兩者各自只定義候選清單和 sentinel 檔案(`models.json` vs `kneron_bridge.py`),減少未來「改了一個忘了改另一個」的風險。 |
| s-2 | `server/main.go` | 146-149 | `dataDir``cfg.DataDir == ""` 時 fallback 到 `builtInDataDir`= bundle 內 read-only 目錄),但這只服務 dev mode。實際執行時 sentinel (`.first-ws-connected`)、custom-models、logs 都可能想寫進去,而 bundle 內目錄在 prod 是 read-only。雖然 dev mode 下通常有寫權限但把「writable user dir fallback 到 read-only bundle dir」這件事放進註解說清楚比較好避免未來有人以為這是正常的 prod 行為。 |
---
## 邊界情況檢查
| 情境 | 行為 | 評估 |
|------|------|------|
| `os.Executable()` 失敗 | `baseDir``"."``resolveBuiltInDataDir``./data``../data``../Resources/data`,其中任一有 `models.json` 就命中 | ✅ 合理,接力自然 |
| 三個候選都沒命中 | 回 `<base>/data``model.NewRepository` 印 warning`r.models = []`server 繼續跑 | ✅ 容錯策略合理(見 Minor m-2若能多印「我試過的路徑」更好 |
| 目錄對但 `models.json` 損毀JSON 解析錯)| `os.Stat` 判定命中 → `NewRepository` `json.Unmarshal` 印 warning → 0 個 model | ✅ 合理,不會誤判成「找別的目錄」 |
| 目錄對但 `models.json` 是 0 bytes | `os.Stat` 判定命中non-dir`NewRepository` `json.Unmarshal` 失敗印 warning → 0 個 model | ✅ 同上 |
| `models.json` 是個目錄(奇怪但可能發生) | `info.IsDir()` 為 true跳過該候選繼續試下一個 | ✅ 已處理 |
| `cfg.DataDir` 指定為不存在路徑 | **只影響 writable 部分**custom-models 讀取會 warning 但 empty、sentinel 寫入失敗但 WS Hub 會跳過、logs 可能寫不出。built-in catalog 不受影響(依然從 `builtInDataDir` 讀) | ✅ 修復後行為比修復前好 —— 修復前一個無效 `--data-dir` 會同時把 built-in catalog 弄掛 |
| `cfg.DataDir` 指定為路徑但 parent 不存在 | 同上 | ✅ |
---
## 其他 dataDir 誤用檢查Grep server/ 全專案)
我 grep 了 `dataDir` / `DataDir` / `models.json` / `nef/kl` / `data/nef` 的所有出現位置,確認:
- `server/internal/api/ws/hub.go``sentinelDataDir`(用於 `.first-ws-connected` sentinel→ ✅ 語意正確,寫入 writable user dataDir
- `server/internal/config/config.go``DataDir` CLI flag → ✅ 沒改
- `server/internal/flash/service.go``s.dataDir` 解析 `data/nef/...` 相對路徑 → ✅ 本次修復已把 `flash.NewService` 改吃 `builtInDataDir`,語意對齊
- `main.go` 其他位置sentinel / custom-models / WS Hub→ ✅ 都正確用 writable `dataDir`,沒有誤用
**沒找到其他地方把「應屬 bundle 內 read-only 的檔案」錯用 writable `dataDir` 解析。** 本次修復清單完備。
---
## 優點
1. **做法對齊既有範式**`resolveBuiltInDataDir` 的候選路徑邏輯1: dev flat / 2: installer / 3: macOS bundle和既有 `resolveBridgeScript` 一致,未來維護者不需學新模式,讀一個就懂兩個。
2. **語意清晰拆分**`builtInDataDir`read-only, bundle 內)和 `dataDir`writable, user home兩個變數名 + 兩段 comment block 把意圖講得很清楚,修復前後讀 code 一看就懂「為什麼要兩個」。`model.NewRepository``flash.NewService` 傳的是 `builtInDataDir``custom-models` / `WS sentinel` 傳的是 `dataDir`,分工直觀。
3. **Dev mode 不 regression**`cfg.DataDir == ""` 時 fallback 回 `builtInDataDir`,讓 `go run ./server` 繼續可跑 —— 重要,因為它同時讓 reviewer 這次能在 `server/` 目錄下獨立驗證 dev mode。
4. **命中條件嚴格**:用「`models.json` 存在且非目錄」當 sentinel而不是 `data/` 目錄存在就行),避免選到 wails build artifact 留下的空 `data/` 目錄(這正是修復前誤入的那個坑)。
5. **容錯好**:三候選都沒命中時 fallback 而非 panic`NewRepository` 也只印 warning 不 crash符合「不 regression、壞的優雅」。
6. **增量最小**:唯一改動檔就是 `server/main.go`+40 6沒動 `flash/service.go` 的 body風險面小。
7. **Build/vet/test 全綠,獨立 smokebundle + dev mode 兩種路徑)也全綠**
---
## 總結意見≤400 字)
M8-10a 這個 P0 latent bug 的修復方向正確、做法乾淨,唯一改動檔 `server/main.go``builtInDataDir`read-only bundle 內)與 `dataDir`writable user home拆成兩個語意明確的變數並新增 `resolveBuiltInDataDir` helper 以 `models.json` 存在為命中條件自動跨 dev / installer / macOS bundle 三種布局。Flash service 同步改吃 `builtInDataDir`,避免相對路徑被誤解到 user home。Build / vet / test / smokebundle + dev mode四項獨立驗證全綠`/api/models` 穩定回 15 個 model實體 .nef 檔路徑可對到 bundle 內。
**但 Linux AppImage 布局(`usr/bin/visiona-local-server` + `usr/lib/visiona-local/data/`三個候選都不命中Major-1**,等於這個 P0 bug 在 Linux 上繼續潛藏——雖然同樣的缺失 `resolveBridgeScript` 先前就存在、並非本次 PR 引入。修復建議:①讀 `VISIONA_BUNDLE_LIB_DIR` env`AppRun` 已 export②加第 4 候選 `<base>/../lib/visiona-local/data`。兩個 Minorfallback 沒 abs 化、沒 log 試過的路徑)和兩個 Suggestion抽公用 helper、dev mode writable fallback 註解)屬改善性質,不阻擋交付。
**結論:⚠️ 需修 1 個 MajorLinux AppImage 覆蓋)後可交付;如果本次交付明確只限 macOS + Windows 兩平台Major-1 可降級為「Linux 平台列為 known issue 追蹤到 M9」並直接放行。建議優先採前者`resolveBridgeScript` 一起修掉,避免兩個函式分開欠債。**
---
## 第二輪 Review2026-04-15
### 審查對象
第一輪所有 Major / Minor / Suggestion 的修復 —— 唯一改動檔 `server/main.go`+119 / 24 行)。
### 第一輪問題處理狀態
| # | 問題 | 狀態 | 驗證 |
|---|------|------|------|
| **Major-1** | Linux AppImage 布局未覆蓋(三候選全不命中) | ✅ **解決** | 候選清單補兩層①env `VISIONA_BUNDLE_LIB_DIR/data`AppRun 已 export② FHS `<base>/../lib/visiona-local/data`。情境 2模擬 AppImage 布局 + env var→ log `Built-in data dir: .../usr/lib/visiona-local/data``Loaded 1``GET /api/models` total=1 ✅;情境 3同布局但不設 env靠 FHS 候選 5 命中)→ 同樣成功 ✅。`resolveBridgeScript` 同步修掉,連帶把第一輪備註的技術債清掉。 |
| **Minor-1** | fallback 沒 `filepath.Abs` 化 | ✅ **解決** | L145-149、L103-107兩個函式 fallback 都過 `filepath.Abs`,絕對路徑能 err 時才退成 Join 的相對路徑(絕不 crash。全不命中情境 log 印出 `Built-in data dir: /var/.../data`(絕對路徑)✅。 |
| **Minor-2** | 沒 log 試過的候選路徑 | ✅ **解決** | L142、L100`log.Printf("warn: ... not found. Tried: %v", tried)``findFirstExisting` 每個候選都先 `filepath.Abs` 再丟進 `tried`log 出來是絕對路徑清單debug 訊息清楚。全不命中情境實測 log 輸出完整 4/5 個候選絕對路徑 ✅。 |
| **Suggestion s-1** | 抽 `findFirstExisting` helper | ✅ **解決** | L53-73 實作 `findFirstExisting(candidates, sentinel) (string, []string)``resolveBuiltInDataDir`L139`resolveBridgeScript`L97都改用。未來改動兩個函式的共通邏輯只需改一處。 |
| **Suggestion s-2** | dev mode writable fallback 加註解 | ✅ **解決** | L180-1905 行 comment block 明確寫出「dev mode-only fallback、production 下 Wails 永遠會傳 `--data-dir`、若 packaged 模式沒傳則 writable 操作 log warning 不 crash」。意圖表達清楚。 |
### 獨立複驗結果
| 驗證項 | 指令 | 結果 |
|-------|------|------|
| Build | `cd server && go build -o /tmp/rv2-server .` | ✅ PASS |
| Vet | `cd server && go vet ./...` | ✅ PASS |
| Test | `cd server && go test -count=1 ./...` | ✅ 全綠api / handlers / ws / device / model 5 個 test package 全通過)|
| 情境 2AppImage + env var | 模擬 `usr/bin/<server>` + `usr/lib/visiona-local/data/models.json``VISIONA_BUNDLE_LIB_DIR=<appdir>/usr/lib/visiona-local` | ✅ `Built-in data dir: .../usr/lib/visiona-local/data``Loaded 1``GET /api/models` total=1 |
| 情境 3AppImage FHS fallback | 同上布局但**不**設 env var | ✅ 候選 5 `<base>/../lib/visiona-local/data` 命中,同樣 `Loaded 1` → total=1 |
| 情境:全不命中 | 把 server binary 放到 `/tmp/xxx/` 獨立目錄,無 env | ✅ 兩個函式 log 出完整 `Tried:` 清單都是絕對路徑fallback 回絕對路徑server 繼續跑 0 models不 crash |
| AppImage 對齊檢查 | 讀 `installer/linux/build-appimage.sh` L29-33 / L124-138 | ✅ `APPDIR/usr/bin/visiona-local-server` + `APPDIR/usr/lib/visiona-local/{data,scripts}` + AppRun export `VISIONA_BUNDLE_LIB_DIR=${HERE}/usr/lib/visiona-local` —— 與 server 候選 ①env 路徑)和 ⑤FHS 路徑)雙雙對齊 |
| cwd 變動風險檢查 | grep 專案找 `os.Chdir` / `Chdir(` | ✅ 零匹配 —— `./scripts` 相對候選在執行期不會因 cwd 漂移而解析錯 |
### 候選優先順序誤命中檢查
逐平台驗證新候選 `<base>/../lib/visiona-local/data` 不會在非 Linux 場景誤命中:
| 平台 | `<base>` | 候選 5 解析 | 該路徑實際是否存在 | 風險 |
|------|---------|-----------|----|------|
| macOS bundle | `Contents/Resources/bin` | `Contents/Resources/lib/visiona-local/data` | ❌macOS bundle 不用 FHS | 無 |
| Windows installer | `{app}\bin` | `{app}\lib\visiona-local\data` | ❌Inno Setup 放 `{app}\data` | 無 |
| Dev mode | `server/``.` | `../lib/visiona-local/data` | ❌ | 無 |
| Linux AppImage | `usr/bin` | `usr/lib/visiona-local/data` | ✅ | 正是目標 |
候選順序把 `<base>/data``<base>/../data``<base>/../Resources/data` 排在 FHS 之前,所有既有布局都會在進 FHS 前命中。**沒有誤命中風險。**
### 新觀察的邊界情況
| # | 觀察 | 評估 | 建議 |
|---|------|------|------|
| E-1 | `findFirstExisting` 簽名 `(string, []string)`,呼叫端用 `dir != ""` 判斷 OK | Go 慣例通常是 `(value, bool)``(value, error)`現行簽名把「hit/miss」和「嘗試清單」合併一個回傳值讀起來略非 idiomatic但因為 `tried` 在 success path 沒用但無害、miss path 才拿去 log合併簽名避免多一個回傳值權衡合理 | 不阻擋通過。若要更 idiomatic 可改 `(dir string, tried []string, ok bool)`,但目前 call site 寫法(`if dir, tried := ...; dir != ""`)已經夠清楚 |
| E-2 | `resolveBridgeScript` / `resolveBuiltInDataDir` 的 warn 走 std `log.Printf`,其他資訊走 `pkglogger.logger.Info` —— log 格式不一致 | resolve 函式在 `main.go` 被呼叫的時序上 logger 已存在L155 `pkglogger.New()`resolve 在 L177、L236**其實可以注入 logger**;但本次修改保留 std log 有個好處:若將來把 resolve 函式移到更早(在 logger 初始化前),不用再改簽名。**不影響正確性。** | 不阻擋通過。建議追蹤到下次重構 logger 時一起統一(加 Minor 備忘) |
| E-3 | `VISIONA_BUNDLE_LIB_DIR` 只在 Linux AppImage AppRun 設定 —— 其他平台若使用者手動設錯,是否會誤命中? | `findFirstExisting` 對每個候選做 `os.Stat(.../sentinel)`env 指到不存在的路徑會 Stat 失敗自然跳下一個候選env 指到存在且含正確 sentinel 的路徑,代表使用者有意識地 override行為合理 | 無風險。Resolve 函式的 `os.Stat` sentinel check 已是自然保險 |
| E-4 | `./scripts` 相對路徑候選resolveBridgeScript 候選 6在理論上會隨 cwd 漂移 | Grep 專案零匹配 `os.Chdir`server 啟動流程不改 cwd且相對候選放最後env / base 相關的絕對候選都不命中才會走到),實務上只在純 dev mode cwd 對齊時觸發 | 無風險 |
### 新發現問題
**Critical無。**
**Major無。**
**Minor非阻擋**
| # | 檔案 | 行 | 問題 | 建議 |
|---|------|----|------|------|
| m2-1 | `server/main.go` | 100, 142 | 兩個 resolve 函式的 warn log 用 std `log.Printf` 而非 `pkglogger.logger.Warn`,與後續 `logger.Info("Built-in data dir: ...")` 格式不一致 | 下次重構 logger 時把 logger或至少 pkglogger注入 resolve 函式,統一格式。不影響本輪通過。 |
**Suggestion**
| # | 檔案 | 行 | 建議 |
|---|------|----|------|
| s2-1 | `server/main.go` | 59-73 | `findFirstExisting` 若改 `(dir string, tried []string, ok bool)` 可讓 call site 更 idiomatic`if dir, tried, ok := ...; ok { ... } else { log; fallback }`)。非必須。 |
### 優點(第二輪新增)
1. **修復完整度超出第一輪要求**:第一輪只要求 Major-1Linux 資料目錄覆蓋Orchestrator 把所有 Minor + 兩個 Suggestion 一併做掉,且把第一輪**備註**的技術債 `resolveBridgeScript` 也同步修了,避免「兩個函式結構一樣但一修一未修」的分裂狀態。
2. **候選順序保守**FHS 候選放在既有候選之後,零誤命中風險(見上表);既有行為零 regression。
3. **`findFirstExisting` 抽象得當**:回傳 `tried` 清單給 log 用的設計雖然非完全 idiomatic但對 debug 訊息清晰度貢獻很大(見全不命中情境 log 輸出)—— 這個權衡合理。
4. **註解品質高**:每個 resolve 函式的候選清單都有編號 + 情境說明;`dataDir` fallback 的註解把「dev-only / production Wails 永遠傳 / 壞了不 crash」三件事講清楚未來維護者不會誤解。
5. **情境 2 / 3 雙路命中**env var 和 FHS 兩層保險,即使未來 AppRun 的 env var 被移掉或名稱改了FHS 還能兜底;反之亦然 —— 防禦性設計好。
6. **所有驗證全綠**build / vet / test / 3 個布局實跑bundle 第一輪已驗 / AppImage-env / AppImage-FHS / 全不命中 fallback全通過。
### 總結意見≤300 字)
Orchestrator 本輪把第一輪的 Major-1 + Minor-1/2 + Suggestion s-1/s-2 全數處理,且連帶修掉備註中 `resolveBridgeScript` 的同樣欠債,新增 `findFirstExisting` 共用 helper 讓兩個函式結構一致。候選清單補 env var`VISIONA_BUNDLE_LIB_DIR`AppRun 已 export+ FHS 雙層保險,對 macOS bundle / Windows installer / Dev mode 零誤命中風險。三個布局實跑AppImage + env var / AppImage FHS fallback / 全不命中 fallback全綠log 明確列出嘗試過的絕對路徑fallback 也已 `filepath.Abs` 化。Build / vet / test 全綠。新觀察到的 2 個 Minor / Suggestionstd log vs pkglogger 格式一致性、helper 簽名 idiomatic 度)屬改善性,不阻擋交付。修復完整度超出第一輪最低要求,技術債清理徹底。
**結論:✅ 通過可交付三平台macOS bundle / Windows installer / Linux AppImage。** 追蹤到下次 logger 重構時統一 std log 與 pkglogger 的格式Minor m2-1

View File

@ -167,12 +167,95 @@
| M8-8 CORS middleware | ✅ + Review 通過 | 127.0.0.1/localhost + suffix attack 防護 | | M8-8 CORS middleware | ✅ + Review 通過 | 127.0.0.1/localhost + suffix attack 防護 |
| MAJ-4 shutdown broadcast | ✅ + Review 通過 | server/ws + visiona-local/notify helper15 test | | MAJ-4 shutdown broadcast | ✅ + Review 通過 | server/ws + visiona-local/notify helper15 test |
| M8-9 Boot-ID + tab 重連 | ✅ + Review 通過 | 9 test + SSR 相容 + reload loop guard | | M8-9 Boot-ID + tab 重連 | ✅ + Review 通過 | 9 test + SSR 相容 + reload loop guard |
| **M8-10 端到端 smoke test + 三平台 build** | **最後一哩** | 交付前事項見下 | | **M8-10 端到端 smoke test + 三平台 build** | 🔄 **進行中** | macOS build ✅ + P0 latent bug 修復 ✅(預設 15 模型載入),待 Reviewer + Windows/Linux 驗證 |
### M8-3 Reviewer 交付前必做事項M8-10 前) ### M8-3 Reviewer 交付前必做事項M8-10 前)
1. **outer repo `visionA/.gitignore` 雖規則對,但 `vendor/ffmpeg/macos/` 內 4 檔未 `git add`** — 交付 commit 時記得一併 stageffmpeg / ffprobe / COPYING.LGPLv3 / BUILD.md 1. ✅ **`vendor/ffmpeg/macos/` 4 檔 git add** — 已於 commit `8cd5751` 處理
2. **`payload/darwin/bin/ffmpeg` 仍是舊 GPL 77MB binary** — M8-10 前必須重跑 `make payload-macos` 2. ✅ **重跑 `make payload-macos`**2026-04-15— payload/darwin 204MB原 GPL 版 ~280MBLGPL 驗證通過ffmpeg 5.7MB + ffprobe 5.6MB,無 yt-dlp 殘留
3. **`vendor/yt-dlp/` 殘留 87MB** — ✅ 已由 Orchestrator 順手清除 3. ✅ **`vendor/yt-dlp/` 87MB 殘留** — 已清除
### M8-10a macOS build + smoke test 結果2026-04-15
**✅ 通過項**
- `make dmg` 成功:**163MB**GPL 版 220 → LGPL 版 163-57MB符合 PRD v2.1 預估)
- `.app` bundle 215MBcodesign verify OK
- LGPL ffmpeg config 驗證:`--enable-version3` + 無 `--enable-gpl` + 無 libx264/libx265只含 mp4/avi/mov/mpeg/mpg 所需 demuxer/decoder符合 R5-6a 最小 decoder-only build
- Server 從 bundle 正常啟動127.0.0.1:3799
- `VISIONA_BUNDLE_BIN_DIR` PATH 注入正確
- `deps/checker.go` **已檢查 ffprobe**progress.md 舊標「⏳ 待補」實際已做,標記更正)
- `[OK] ffmpeg: (bundled)`
- `[OK] ffprobe: (bundled)`
- `[OK] python3: Python 3.14.3`
- `GET /` → HTTP 200 size=24292Next.js 首頁)✅ splash regression 不再發生
- `GET /api/system/health``{"status":"ok"}`
- `GET /api/system/deps` → 三項全 available ✅
- `GET /api/devices` → 200空陣列無裝置
- SIGTERM 優雅關閉 ✅
- CORS middleware init 無錯 ✅
### 🔴 M8-10a 抓到的 P0 latent bug從 M1 就有,只是沒人測過)
**現象**`GET /api/models``{"data":{"models":null,"total":0},"success":true}`
啟動 log`Loaded 0 built-in models` + `Warning: could not load models from .../bin/data/models.json: no such file`
**根因**`server/main.go:42-51` + `:99-108`
- server 預設 `base = filepath.Dir(exe)` = `Contents/Resources/bin/`
- 預設 `dataDir = base + "/data"` = `Contents/Resources/bin/data/`(空目錄)
- 但 models.json + 8 個 .nef 實際住在 `Contents/Resources/data/`(上一層)
- Wails 端 `server_control.go:529` 明確傳 `--data-dir a.dataDir`,而 `a.dataDir = platformDataDir()` = `~/Library/Application Support/visiona-local/` — 使用者 dataDir也**沒有** models.jsonuser dataDir 只存 lock / ipc-port / logs / custom-models / preferences.json
- **結論**:正式啟動路徑下永遠載入 0 個預設模型
**為什麼 M1-M7 都沒抓到**:當時 smoke test 只測 `/api/health``/`、splash 跳轉,從沒跑過 `/api/models`
**這違反 R5 第 9 點共識**:「預設模型維持 8 個 .nef只能上傳 = 再次確認不做 Model Zoo」— 8 個預設模型必須能載入,使用者才有基本 demo 體驗。
**影響範圍**macOS / Windows / Linux 三平台都同樣這個 bugserver/main.go 是共用的)。
**採方案 B使用者批准+ 額外職責拆分**2026-04-15
實作:`server/main.go`
- 新增 `resolveBuiltInDataDir(base)` — 照 `resolveBridgeScript` 同款風格,依序試 `<base>/data``<base>/../data``<base>/../Resources/data`**以 `models.json` 存在為命中條件**
- `main()` 拆出兩個獨立變數:
- `builtInDataDir`read-onlybundle 內)— 給 `model.NewRepository(filepath.Join(builtInDataDir, "models.json"))``flash.NewService(deviceMgr, modelRepo, builtInDataDir)` 使用(因 flash 也要解析 model.filePath 相對路徑 `"data/nef/..."`
- `dataDir`writableuser home— 給 custom-models / sentinel file / logs 使用,語意不變
- `cfg.DataDir == ""` 時 fallback 成 `builtInDataDir`(保 dev mode `go run ./server` 繼續可跑)
**為什麼順便拆職責**:原本的 bug 不只影響 `modelRepo`,也影響 `flash.Service``flash.service.go:115-121``s.dataDir` 解析 `"data/nef/kl520/xxx.nef"` → 原本會指向 user dataDir 找不到檔案)。純 B 只修 main.go 一處還不夠,必須同時把 flash 切到 builtInDataDir。拆成兩個變數反而讓職責更清楚未來不會再混淆。
**驗證結果**
- `go build / vet / test -count=1 ./...` 全綠
- 重 build dmg 163MB大小不變
- Smoke test `/api/models``total: 15`(不是原估計的 8因為 models.json 有 15 個條目,部分 model 共用 nef
- 啟動 log`Built-in data dir: .../Contents/Resources/data` + `Loaded 15 built-in models` + 無 `could not load models` warning
- `/api/models/kl520-yolov5-detection` 回傳完整 metadata + filePath `data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef`
- flash 解析後指向的實體檔案在 bundle `.../data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef`7.2MB)與 `kl720/...` 10MB與 API 回傳的 modelSize 完全吻合 ✅
### Reviewer 第一輪2026-04-15 Major 1 / Minor 2 / Suggestion 2
報告:`.autoflow/05-implementation/reviews/review-m8-10a-builtin-data-dir-fix.md`
- **Major-1**Linux AppImage 布局(`usr/bin/<exe>` + `usr/lib/visiona-local/data/`三候選全不命中AppRun 已 export `VISIONA_BUNDLE_LIB_DIR` 但 server 沒讀。備註 `resolveBridgeScript` 先前就有同樣缺失。
- **Minor-1**fallback 沒 `filepath.Abs`
- **Minor-2**fallback 沒 log 試過的候選
- **Suggestion s-1**:抽公用 `findFirstExisting` helper
- **Suggestion s-2**dataDir dev mode fallback 註解
### Reviewer 第二輪修復2026-04-15Major + 所有 Minor + 兩個 Suggestion 一次全部處理
- 新增 `findFirstExisting(candidates, sentinel) (dir, tried)` helpers-1
- `resolveBuiltInDataDir` 候選 5 條①env `VISIONA_BUNDLE_LIB_DIR/data``<base>/data``<base>/../data``<base>/../Resources/data``<base>/../lib/visiona-local/data`
- `resolveBridgeScript` 比照修復(技術債一起清),候選 6 條
- fallback 全 `filepath.Abs`m-1+ `log.Printf("warn: ... Tried: %v", tried)`m-2
- `main()` dataDir fallback 加 5 行註解解釋 dev-only 語意s-2
### 第二輪 Review2026-04-15✅ 通過,可交付三平台
- 逐項驗證Major-1 ✅ / Minor-1 ✅ / Minor-2 ✅ / s-1 ✅ / s-2 ✅
- 獨立複驗build / vet / test 全綠AppImage 模擬env var 路徑AppImage 模擬FHS fallback 無 env全不命中情境 log + fallback + server 不 crash ✅;`os.Chdir` grep 零匹配(`./scripts` 相對候選無 cwd 漂移);候選順序對非 Linux 三平台零誤命中
- 新發現兩項**非阻擋**
- Minor m2-1resolve 函式用 std `log.Printf` 而非 `pkglogger.Warn`logger 尚未初始化前呼叫,合理),下次 logger 重構時統一
- Suggestion s2-1`findFirstExisting` 可改 `(dir, tried, ok bool)` 更 idiomatic非必須
### M8-10b/c 待使用者驗證
- **Windows**:使用者在 Windows 實機跑 bootstrap + make exe → 驗證 splash → Wails 控制台 6 階段啟動 → 瀏覽器 Web UI
- **Linux**Ubuntu 實機跑 bootstrap-linux.sh + make appimage → 驗證 xdg-open 預設 OFF + notify-send fallback
### M8-3 Minor + Suggestion低優先 ### M8-3 Minor + Suggestion低優先
- **Minor**BUILD.md §Verification §5 預期 `spctl --assess=accepted` 實測會被 reject改為 `codesign -v` - **Minor**BUILD.md §Verification §5 預期 `spctl --assess=accepted` 實測會被 reject改為 `codesign -v`

View File

@ -50,31 +50,103 @@ func baseDir(devMode bool) string {
return filepath.Dir(exe) return filepath.Dir(exe)
} }
// resolveBridgeScript finds kneron_bridge.py across different packaging layouts. // findFirstExisting tries each candidate directory and returns the first one
// that contains `sentinel` as a regular file. Returned path is absolute.
// //
// Possible locations (tried in order): // If no candidate hits, returns ("", tried) where `tried` is the absolute
// 1. <base>/scripts/kneron_bridge.py — dev mode or flat layout // form of every candidate that was checked — callers can log this for
// 2. <base>/../scripts/kneron_bridge.py — Windows/Linux installer: binary in {app}/bin, scripts in {app}/scripts // debugging. Callers are expected to supply their own fallback value.
// 3. <base>/../Resources/scripts/kneron_bridge.py — macOS app bundle: binary in Contents/MacOS, scripts in Contents/Resources func findFirstExisting(candidates []string, sentinel string) (string, []string) {
// 4. ./scripts/kneron_bridge.py — cwd fallback tried := make([]string, 0, len(candidates))
func resolveBridgeScript(base string) string {
candidates := []string{
filepath.Join(base, "scripts", "kneron_bridge.py"),
filepath.Join(base, "..", "scripts", "kneron_bridge.py"),
filepath.Join(base, "..", "Resources", "scripts", "kneron_bridge.py"),
filepath.Join(".", "scripts", "kneron_bridge.py"),
}
for _, c := range candidates { for _, c := range candidates {
abs, err := filepath.Abs(c) abs, err := filepath.Abs(c)
if err != nil { if err != nil {
tried = append(tried, c)
continue continue
} }
if info, err := os.Stat(abs); err == nil && !info.IsDir() { tried = append(tried, abs)
return abs if info, err := os.Stat(filepath.Join(abs, sentinel)); err == nil && !info.IsDir() {
return abs, tried
} }
} }
// Nothing found — return the default so downstream logs a clear error return "", tried
return filepath.Join(base, "scripts", "kneron_bridge.py") }
// resolveBridgeScript finds the directory holding kneron_bridge.py across
// different packaging layouts, then returns the absolute path to the script.
//
// Possible locations (tried in order):
// 1. <env VISIONA_BUNDLE_LIB_DIR>/scripts — Linux AppImage (AppRun exports this)
// 2. <base>/scripts — dev mode or flat layout
// 3. <base>/../scripts — Windows/Linux installer: {app}/bin/<exe>, {app}/scripts/
// 4. <base>/../Resources/scripts — macOS app bundle: Contents/Resources/bin/<exe>, Contents/Resources/scripts/
// 5. <base>/../lib/visiona-local/scripts — Linux AppImage FHS: usr/bin/<exe>, usr/lib/visiona-local/scripts/
// 6. ./scripts — cwd fallback
func resolveBridgeScript(base string) string {
candidates := []string{}
if libDir := os.Getenv("VISIONA_BUNDLE_LIB_DIR"); libDir != "" {
candidates = append(candidates, filepath.Join(libDir, "scripts"))
}
candidates = append(candidates,
filepath.Join(base, "scripts"),
filepath.Join(base, "..", "scripts"),
filepath.Join(base, "..", "Resources", "scripts"),
filepath.Join(base, "..", "lib", "visiona-local", "scripts"),
filepath.Join(".", "scripts"),
)
if dir, tried := findFirstExisting(candidates, "kneron_bridge.py"); dir != "" {
return filepath.Join(dir, "kneron_bridge.py")
} else {
log.Printf("warn: kneron_bridge.py not found. Tried: %v", tried)
}
// Fallback — return the default so downstream logs a clear error
abs, err := filepath.Abs(filepath.Join(base, "scripts", "kneron_bridge.py"))
if err != nil {
return filepath.Join(base, "scripts", "kneron_bridge.py")
}
return abs
}
// resolveBuiltInDataDir finds the bundle-internal data/ directory that ships
// with the binary. This directory is *read-only* at runtime and holds the
// built-in model catalog (models.json + nef/kl520/ + nef/kl720/).
//
// This is different from the user data directory (lock, ipc-port, logs,
// custom-models, preferences.json, sentinel file) which is writable and lives
// under the OS-specific app-data location. See main() for the split.
//
// Possible locations (tried in order):
// 1. <env VISIONA_BUNDLE_LIB_DIR>/data — Linux AppImage (AppRun exports this)
// 2. <base>/data — dev mode or flat layout (cwd == repo/server/)
// 3. <base>/../data — Windows/Linux installer: {app}/bin/<exe>, {app}/data/
// 4. <base>/../Resources/data — macOS app bundle: Contents/Resources/bin/<exe>, Contents/Resources/data/
// 5. <base>/../lib/visiona-local/data — Linux AppImage FHS: usr/bin/<exe>, usr/lib/visiona-local/data/
//
// A candidate counts as a hit only if models.json exists inside it as a
// regular file — this avoids false positives from empty `data/` directories
// that Wails sometimes leaves behind in build artifacts.
func resolveBuiltInDataDir(base string) string {
candidates := []string{}
if libDir := os.Getenv("VISIONA_BUNDLE_LIB_DIR"); libDir != "" {
candidates = append(candidates, filepath.Join(libDir, "data"))
}
candidates = append(candidates,
filepath.Join(base, "data"),
filepath.Join(base, "..", "data"),
filepath.Join(base, "..", "Resources", "data"),
filepath.Join(base, "..", "lib", "visiona-local", "data"),
)
if dir, tried := findFirstExisting(candidates, "models.json"); dir != "" {
return dir
} else {
log.Printf("warn: built-in data dir (models.json) not found. Tried: %v", tried)
}
// Fallback — return the default so downstream logs a clear error
abs, err := filepath.Abs(filepath.Join(base, "data"))
if err != nil {
return filepath.Join(base, "data")
}
return abs
} }
func main() { func main() {
@ -96,18 +168,38 @@ func main() {
// Check external dependencies // Check external dependencies
deps.PrintStartupReport(logger) deps.PrintStartupReport(logger)
// Resolve base directory and data directory // Resolve base directory.
base := baseDir(cfg.DevMode) base := baseDir(cfg.DevMode)
// Resolve built-in data directory (read-only, ships with the binary).
// Holds models.json + nef/kl520/ + nef/kl720/. Auto-detected across
// dev / installer / macOS-bundle layouts; see resolveBuiltInDataDir().
builtInDataDir := resolveBuiltInDataDir(base)
logger.Info("Built-in data dir: %s", builtInDataDir)
// Resolve user data directory (writable). Holds lock, ipc-port, logs,
// custom-models, preferences.json, sentinel. Wails passes this via
// --data-dir pointing at the OS app-data location.
//
// Standalone fallback: when no --data-dir is given we reuse builtInDataDir
// so `go run ./server` and direct binary launches keep working for local
// development. In *production*, Wails always passes --data-dir, so this
// branch never lands on a read-only bundle path. If someone does run the
// packaged binary with no --data-dir, the writable operations (sentinel,
// logs, custom-models) will fail against the read-only bundle dir and the
// affected code paths log warnings — they don't crash the server.
dataDir := cfg.DataDir dataDir := cfg.DataDir
if dataDir == "" { if dataDir == "" {
dataDir = filepath.Join(base, "data") dataDir = builtInDataDir
} }
// Initialize model repository (built-in models from JSON) // Initialize model repository (built-in models from JSON).
modelRepo := model.NewRepository(filepath.Join(dataDir, "models.json")) // Always read from the built-in data dir — not the user data dir —
// so Wails passing --data-dir doesn't accidentally blank out the catalog.
modelRepo := model.NewRepository(filepath.Join(builtInDataDir, "models.json"))
logger.Info("Loaded %d built-in models", modelRepo.Count()) logger.Info("Loaded %d built-in models", modelRepo.Count())
// Initialize model store (custom uploaded models) // Initialize model store (custom uploaded models) — writable, user dataDir.
customModelDir := cfg.ModelDir customModelDir := cfg.ModelDir
if customModelDir == "" { if customModelDir == "" {
customModelDir = filepath.Join(dataDir, "custom-models") customModelDir = filepath.Join(dataDir, "custom-models")
@ -150,8 +242,11 @@ func main() {
// Initialize camera manager // Initialize camera manager
cameraMgr := camera.NewManager() cameraMgr := camera.NewManager()
// Initialize services // Initialize services.
flashSvc := flash.NewService(deviceMgr, modelRepo, dataDir) // flash.Service resolves relative `.nef` paths from models.json against
// builtInDataDir (not dataDir), since the .nef files ship alongside
// models.json in the read-only bundle, not in the writable user dataDir.
flashSvc := flash.NewService(deviceMgr, modelRepo, builtInDataDir)
inferenceSvc := inference.NewService(deviceMgr) inferenceSvc := inference.NewService(deviceMgr)
// Determine static file system for embedded frontend // Determine static file system for embedded frontend