launchd 自啟:不含機密的 plist template

讓 nomad-hub 開機自動服務於 127.0.0.1:8091、崩潰自動重啟。 關鍵設計原則是佔位符版 plist:所有路徑(uv 絕對路徑、工作目錄、log 目錄) 用 __UV_BIN__/__WORKDIR__/__LOG_DIR__ 佔位,安裝腳本 sed 套入。 plist 本身不含任何機密,可以安全 commit 進 repo。

幾個防坑設計:--no-reload 避免 launchd 下的 reloader subprocess 問題、 uv 用絕對路徑呼叫(launchd 的 PATH 極簡,不能靠 shell lookup)、 log 同時接 stdout 和 stderr(uvicorn 寫 stderr,看到 0 bytes stdout 不代表沒跑)、 KeepAlive 加 ThrottleInterval=10(崩潰重啟有緩衝,不會瘋狂循環)。 驗收方式:殺掉 pid,確認 ~10 秒後被自動重生到新 pid。

OpenAPI 文件化:artifact 不過期測試

FastAPI 本就自動生成 /openapi.json,任務轉為「讓 openapi.json 成為一級交付物」—— 提供 CLI 匯出指令,並把 docs/openapi.json 加進 repo 做版本追蹤。

artifact 過期問題是這裡最值得記的設計:如果 schema 改了但 committed openapi.json 沒更新, 怎麼被測試抓到?一開始想用文字比對,但 JSON 序列化有 whitespace/encoding 差異, 容易出現格式假失敗。改用 dict 比對:json.load(committed_file) == app.openapi()。 只抓真實 schema drift,對格式差異完全免疫。收工流程現在有一條規則: 改路由後必跑 nomad-hub openapi -o docs/openapi.json,否則 staleness 測試會擋。

E2E Playwright:不動 registry 的隔離測試

E2E 測試的 conftest.py 用 session fixture 自己起 nomad-hub 子進程, 選一個閒置 port,輪詢 /health 等服務就緒,整個 E2E suite 用唯讀流程—— 不動 registry,不寫任何狀態。

marker 隔離是另一個實用做法:pytest marker 標 e2e,addopts -m 'not e2e' 讓一般 pytest 預設排除, 保持 CI 快速。要跑 E2E 時明確傳 -m e2e,並加 --no-cov(不能用 -p no:cov, 因為 addopts 裡有 --cov,衝突只能用 --no-cov 覆蓋)。

語意 job 進度細分:關注點分離

原本語意分析的 job 狀態只有 running/completed/failed,前端不知道進行到哪裡。 改成 6 階段:analyzing → prompting → summary → layers → tour → applying。

架構選擇有意思:pipeline 的階段知識(每個階段叫什麼、順序怎麼排)放在 runner 裡, job manager 只做通用的進度儲存——它接受 verbatim 的字串,不認識「semantic stages」這個概念。 這讓 job manager 可以被其他類型的 job 複用,而不需要知道任何業務邏輯。 晚到的進度回報不會復活或污染已終結的 job(只在 running 狀態才更新)。

SQLite CURRENT_TIMESTAMP 的秒級陷阱

gemma sessions CRUD 的 list_sessions 要依「最近更新」排序, 用 ORDER BY updated_at DESC。測試通過,但發現同秒建立的兩個 session 排序不穩定。

根因:SQLite CURRENT_TIMESTAMP 是秒級解析度。同一秒內的兩筆記錄 updated_at 完全相同, ORDER BY 結果不可預測。治本:改用 strftime('%Y-%m-%d %H:%M:%f','now'), 取得毫秒精度的時間戳。DEFAULT 值語法要用括號:DEFAULT (strftime(...)), 不然 SQLite 會報 syntax error。

同時發現一個 lazy init 的好處:GemmaSessionStore 原本在 create_app() 時就建 DB 連線和表, 導致每個測試中呼叫 create_app() 預設路徑都會在真實 data/ 建立檔案,污染測試目錄。 改成 lazy init——建構子不碰磁碟,第一次查詢才 ensure_initialized()——讓 chat-only 的測試 不觸碰 session 路徑,DB 檔案從來不出現在測試環境。

關鍵教訓

plist template 用佔位符,路徑不硬編:路徑因機器而異,hardcode 進 plist 就不能 commit 進 repo,也不能給別人用。佔位符 + 安裝腳本的組合讓 launchd 配置可以被版本管理。

artifact 同步用 dict 比對,不用文字比對:JSON 格式差異(空格、key 順序)會讓文字比對誤失敗。dict 比對只測真實的 schema 差異,是比較乾淨的 staleness 守護。

SQLite 排序要考慮時間精度:CURRENT_TIMESTAMP 是秒級的,同秒內排序不穩定。需要可靠排序時改用 strftime('%f') 取毫秒,或用應用層時間戳。

共用資源用 lazy init,不在 create_app 時初始化:eager init 讓所有 create_app 呼叫者都污染真實資源。lazy init 只在真正需要時才碰磁碟,測試隔離乾淨。

連續五輪節奏讓清單不堆積:問方向 → 實作 → 雙驗證 → commit → 更新記憶,每輪原子完整,不留「完成 80% 的半成品」。清單積壓通常是因為每輪結束時沒有真正收尾。

來源:個人開發日誌 2026-06-03 · nomad-hub Phase 5 · 6 commit · 256→290 tests · 93% coverage · 4 e2e