diff --git a/docs/autoflow/04-architecture/adr/adr-018-agent-device-model.md b/docs/autoflow/04-architecture/adr/adr-018-agent-device-model.md new file mode 100644 index 0000000..3eda890 --- /dev/null +++ b/docs/autoflow/04-architecture/adr/adr-018-agent-device-model.md @@ -0,0 +1,167 @@ +# ADR-018: 一 agent 多 device 資料模型 + serial 路由(走向 A') + +## 狀態 +Accepted(使用者已裁決同意,2026-07-09) + +## 日期 +2026-07-09 + +## 作者 +Architect Agent + +--- + +## 1. 背景與範圍 + +visionA 的「個人設備管理」要支援 **一個 local agent(一條已配對的 tunnel 連線)底下掛多顆實體 USB 裝置(Kneron dongle,以 kn_number 序號識別)**。這與現況資料模型的根本衝突是:現況把 `devices` 這張表當「一條連線」用(exchange 時 `uuid.NewString()` 自建一筆佔位 device 綁 session token),從未表達「一顆實體 USB」的概念。 + +本 ADR 收斂以下決策,讓後續 task 3(三色分色)/ task 5(註冊)/ task 6(去重)/ task 7(實體刪除)長在乾淨模型上,而非各自打 patch: + +- **走向抉擇**:如何在資料層表達「一 agent 多 device」,且不破壞剛做完的推論 / camera / flash proxy 路由。 +- **路由對應**:雲端 `devices.id`(UUID)與 local agent 本地 session key(合成 `kl520-0`)從不對齊,多裝置一定路由錯,如何建立跨層穩定識別。 +- **前端識別模型**:前端所有 device 操作現在都帶雲端 UUID,改用 serial 路由後哪些操作用 UUID、哪些用 serial。 + +### 1.1 定案前提(使用者已拍板) + +- device = 實體 USB 裝置,以 Kneron 序號 kn_number 識別;一 agent 多 USB。 +- 註冊軸 `registered_at`(NULL=未註冊)× 連線軸 `tunnel_online` → 三色狀態。 +- Q3 手動逐顆註冊;Q4 kn_number 唯一鍵(多租戶複合鍵留 Phase1);Q5 取消註冊 = 真實體刪除 + 清 `session_tokens` FK。 +- 授權範圍:可動 `local-agent/`、`visionA-backend/`、`visionA-frontend/`;不動 `local-tool/`(可複製、不動)。 + +### 1.2 讀 code 後推翻的悲觀假設(本 ADR 的核心洞察) + +前置盤點(`personal-device-mgmt-audit.md`)曾判定「B 走向硬傷 = `session_tokens.device_id` 單一 FK」。**讀完 `proxy.go` 後,這個判定被推翻**: + +| 原假設 | 讀 code 後的事實 | source | +|--------|-----------------|--------| +| 「一 agent 多 USB → 一條 tunnel 對應多顆 device,撞 `session_tokens.device_id` 單一 FK」 | **推論路由不經過 `session_tokens.device_id`**。`proxy.go:278 pickActiveSessionToken` 只按 `userID` 挑該 user 的 active session,`:id`(deviceId)**原樣透傳**給 local agent 當 path 參數,不參與選 tunnel | `proxy.go:278` / `proxy.go:130-134` 註解 | +| 「`session_tokens.device_id` NOT NULL 一對一是硬傷」 | 它綁的是 exchange **自建的佔位 device**(`pairing_exchange.go:115` `uuid.NewString()`),這筆 device 是「agent 連線」語意、不是任何一顆實體 USB。推論時完全沒用到它 | `pairing_exchange.go:115` | + +**真正的缺口不是 FK,是「雲端 device.id ↔ local agent 本地 id」的對應從不存在**:雲端 `devices.id` = DB UUID,local agent 路由 key = `fmt.Sprintf("%s-%d", chip, idx-1)`(合成 `kl520-0`,`detector.go:231`),兩者從沒對齊。單裝置 demo 能跑純屬 `device-store.ts:88-92` 記載的寬鬆比對技術債,多裝置一定錯。**這個對應的建立,才是本設計的真正重心。** + +--- + +## 2. 決策 + +### 2.1 走向 A':新增 `agents` 實體 + `devices` 改實體 USB + session 綁「agent 代表 device」+ serial 路由 + +採用 **走向 A'**: + +1. **新增 `agents` 表**(一條已配對的 tunnel 連線 = 一個 agent),`owner_user_id FK→users`。 +2. **`devices` 語意改為「一顆實體 USB」**,新增 `agent_id FK→agents`、`serial_number`(kn_number,欄位已存在,task 1 填值)、`registered_at`(NULL=未註冊)、`is_representative`(true=agent 佔位/代表 device,非真 USB)、`agent_local_device_id`(local 合成 id,路由除錯輔助)。 +3. **`session_tokens.device_id` 物理 schema 不動**(保留 `NOT NULL REFERENCES devices(id)`),但**改綁「agent 的 representative device」**——每個 agent 建一筆 `is_representative=true`、`serial_number=NULL` 的代表 device,等同現行 exchange 自建的那筆。推論路由既然不靠它,改綁零風險。 +4. **路由統一用 serial(kn_number)**:local agent 端新增 `serialToLocalID` 反查表,`GetDevice(id)` 改為雙查(先 `sessions[id]`、查無再 `serialToLocalID[id]`,`manager.go:118-126`)。純加法、不改 `sessions` key、不改 proxy——保護剛做完的推論。 + +### 2.2 前端 FE-A 混合識別模型(併入 R-Route 查證結論) + +R-Route 前端查證(讀 `device-store.ts` / `workspace-client.tsx` / `media.ts` / `camera_handler.go` 等)發現:**前端帶 device id 的方式不統一**: + +- **path 類**(`/api/devices/{id}/...`、`/ws/devices/{id}/inference`):connect / disconnect / unpair / 詳情 fetchDevice / inference WS。 +- **body / FormData 類**(`{ deviceId }` / `form.append("deviceId", …)`):camera start/stop、media upload(image/video/batch)。 + +因此原設計「用 serial 當 **path** 識別」只對一半。**正確落地 = 前端把 device 識別「值來源」從 `device.id`(UUID)換成 `device.serialNumber`,傳輸位置(path vs body)完全不動**。且採 **FE-A 混合模型**: + +| 操作類別 | 用哪個識別值 | 理由 | +|---------|------------|------| +| **純雲端 DB 操作**(列表 / 詳情 fetchDevice / unpair) | **UUID** | 在 api-server 直接查 DB,UUID 是主鍵最自然;且 serial 空的 device 也要能看詳情 / 被移除 | +| **路由到 local agent 的操作**(camera / media / inference WS / flash / connect) | **serial** | 才能對上 local agent `sessions`(雙查反查表) | + +**被否決的 FE-B(全部用 serial)**:會讓 serial 空(未串通 / 假序號)的 device 連移除都做不到,不可接受。 + +**serial 空的 device**:無法路由,「路由類」操作按鈕須 **disable**(新增 `hasSerial` 條件,疊加現有 `isOnline`),並 tooltip 說明「此裝置尚未取得序號,無法操作」。假序號 `0x00000000`(macOS 無 SDK)視同無序號、寫 NULL。 + +--- + +## 3. 考慮過的替代方案 + +| 走向 | schema 動作 | `session_tokens.device_id` | device.id ↔ local id 對應 | task 1 能否先行 | 未來債 | 排除原因 | +|------|-----------|---------------------------|--------------------------|----------------|--------|---------| +| **A(新增 agents + session 改綁 agent)** | 大:新表 + devices 加 FK + **改 `session_tokens` 的 FK target(devices→agents)** | 改 `REFERENCES agents(id)` | 要建 | 否(連帶動一堆) | 低 | **破壞性 migration**:既有 session token 的 device_id 值要重新 map;而推論根本不靠它,改它純為「乾淨」承擔風險,ROI 低 | +| **B(沿用 devices 改語意 + 加 registered)** | 小:devices 加 `registered_at` | 維持綁「第一顆 device」→ N>1 時語意崩 | 要建 | 勉強 | **高** | 多 USB 時 FK 語意崩、是半套 A,留技術債 | +| **A'(採用)新增 agents + devices 掛 agent_id + session 綁代表 device + serial 路由** | 中:新表 agents + devices 加欄位 + **不改 `session_tokens` 物理 schema**(改語意) | 維持 NOT NULL、綁 agent 的代表 device | 用 serial(kn_number)建立 | **是**(task 1 只填 serial、agents 表可後補) | 低 | ✅ 採用 | + +**前端識別模型的替代方案**: + +| 方案 | 前端改動 | 排除原因 | +|------|---------|---------| +| **FE-A(採用)混合**:DB 操作用 UUID、路由操作用 serial | 識別值來源分流,語意最正確 | ✅ 採用 | +| FE-B 全部用 serial | 連 unpair / 詳情都用 serial,api-server 要改 DB 層支援 `GetBySerial` 查 | serial 空的 device 連詳情 / 移除都進不去,不可接受 | +| B2 前端維持 UUID、local agent 建 UUID→本地 key map | 前端零改動 | local agent 要在配對時取得雲端 UUID 並建 map,耦合重(等同原設計選項 A,已否決) | + +--- + +## 4. 後果 + +### 4.1 正面 +- **語意乾淨**:`agents`(一條 tunnel 連線)與 `devices`(一顆實體 USB)分離,正是使用者要的模型。task 3/5/6/7 長在乾淨模型上。 +- **不動 `session_tokens` 物理 schema**:避開改 FK target 的高風險 migration,只改語意(綁代表 device)。 +- **路由對應用 serial 建立**:serial 是硬體穩定值、跨雲端↔local 一致。local agent 端純加法(`serialToLocalID` + `GetDevice` 雙查),舊呼叫(本地 id)與新呼叫(serial)都通。 +- **task 1(序號地基)可先行**:只需 `devices.serial_number` 填值(欄位已存在),`agents` 表與 `agent_id` 可在 task 5/6 補(migration 分兩支:task 1 不動 schema、0005 建 agents 模型)。序號本身可獨立交付、模型另案,兩者不衝突。 +- **保護剛做完的推論**:proxy.go 一行都不用改(路由機制天然支援多 USB);local agent `sessions` map 的 key 不改;`GetDevice` 雙查向下相容。 + +### 4.2 負面(接受的取捨) +- **多一張 `agents` 表 + 一次資料遷移**:現有「device=連線」的舊 row 要拆成「1 agent + N device(representative)」(遷移邏輯見 §5.4)。 +- **local agent 端新增「serial ↔ 本地 sessions key」對應**:這是 A/A' 共同的必要工作,B 走向也躲不掉。 +- **`session_tokens.device_id` 綁「代表 device」是語意上的 workaround**:它不是真正的 USB。若未來要「session 綁具體某顆 USB」(例如 per-device token)要再改,但目前無此需求(一條 tunnel 服務該 agent 底下所有 USB),可接受。 +- **前端 FE-A 混合的認知成本**:工程師須清楚區分「哪個操作用 UUID、哪個用 serial」(R-Route 查證已列出逐檔清單 C1–C8)。 + +### 4.3 風險 +- **R-Route(路由對應)**:serial 空的 device(未串通 / 假序號)無法路由,「路由類」操作須 disable。若使用者期待「所有 device 都能操作」,需先補齊 serial 串通(task 1)。 +- **connect/disconnect 歸屬待查**:connect/disconnect 是否 forward 到 local agent(決定歸 serial 或 UUID)需在 WP-C 開工第一件事查 backend `devicesConnectHandler` 是否 proxy 到 local agent。目前 device-store 只打 API 看不出。 +- **資料遷移正確性**:現有 device 全部 serial=NULL、全部視為 representative(本來就是「連線」不是 USB),遷移後繼續綁原 session_tokens,現有 tunnel/推論流程須驗證零影響。真實 USB device 遷移後還不存在,等 agent 下次 exchange 帶 serial 上來或 task 5 註冊才建立。 +- **dbtest 陷阱**:本機無 docker,跑 migration 測試用 `192.168.0.130` 跑 testcontainers;`go test -run xxx -list` 先確認 case 數再實跑,避免「pool 參數混入 migration」「檔名沒 `_test.go`」兩種假綠。0005 up/down migration 須有對應 `*_test.go` 驗證 apply/rollback 對稱。 + +--- + +## 5. 落地方向(schema 形狀,細節由 backend / frontend agent 落地) + +> 此為決策方向。schema DDL 細節、migration 檔、前端逐檔改動由後續實作落地。schema 併入 `database.md`、TDD 由 Orchestrator 另案處理,本 ADR 只定模型形狀與決策歷史。 + +### 5.1 目標 ER(A' 模型) + +``` +users (已存在) 1─N agents ★新表(一條 tunnel 連線 = 一個 agent) + id / owner_user_id FK→users / name / platform / agent_version / last_paired_at + +agents 1─N devices(語意改為「實體 USB」) + id (UUID PK,雲端穩定 id) + owner_user_id FK→users + agent_id FK→agents ★新增(NULL=代表 device / 舊資料) + serial_number(kn_number,已存在,task 1 填值) + agent_local_device_id ★新增(local 合成 id,路由除錯輔助) + registered_at ★新增(NULL=未註冊) + is_representative ★新增(true=agent 佔位/代表 device) + remote_status / status / last_seen_at / deleted_at(已存在,task7 改實體刪除) + +session_tokens(物理 schema 不動) + device_id NOT NULL FK→devices ← 改綁「agent 的 representative device」(語意變、schema 不變) +``` + +### 5.2 migration 分支 +- **task 1(序號地基)無 migration 檔**:`serial_number` 欄、SaveTx 空→NULL、GetBySerial、partial unique 全已存在。工作純在 Go 層(exchange 收 serial 填入 + 防唯一約束炸裂:exchange 前 `GetBySerial(owner, serial)` 已存在則復用既有 device_id)。 +- **0005(agents 模型)**:`CREATE TABLE agents` + `devices` 加 4 欄 + index(`idx_devices_agent_active` / `idx_devices_registered`)+ data migration(舊 device → agent + 標 representative)。環境:PostgreSQL 14.23,`gen_random_uuid()` 可直接用。 + +### 5.3 唯一約束(task 6 去重基礎) +現有 `uq_devices_owner_serial_active (owner_user_id, serial_number) WHERE deleted_at IS NULL` **不需改**:representative device serial=NULL(NULL 互不相等、天然共存);假序號寫 NULL 不撞;Q4「多租戶複合鍵留 Phase1」指跨租戶全域唯一,現階段 owner-scoped 已足夠。只要 serial 有值(task 1)就自動生效。 + +### 5.4 資料遷移 +每筆現有 device(皆 serial=NULL 的連線佔位)→ 建對應 agent + `UPDATE device SET agent_id, is_representative=true`(SQL 無法一句對應,建議 migration 程式碼逐筆)。List devices 時 filter `is_representative=false`(只列真 USB),且加 `WHERE ... AND is_representative=false`。 + +### 5.5 前端 WP-C 逐檔(FE-A 混合) +C1 `device-store.ts` 加 `serialNumber` type + normalize;C2 `workspace-client.tsx` camera/media/inference 識別改 serial + disable;C3 `use-inference-stream.ts` 註解;C4 `lib/media.ts` 註解;C5 `workspace/[deviceId]` 連結改帶 serial;C6 `device-card.tsx` 工作區按鈕加 `hasSerial`;C7 `device-detail-client.tsx` 工作區用 serial(詳情頁本身 fetchDevice 維持 UUID);C8 flash(F8 接時用 serial)。**不改(保持 UUID)**:fetchDevices/fetchDevice/unpair/connect/disconnect、詳情頁路由 `/devices/[id]`。 + +--- + +## 6. 合規性 +- [x] 與使用者確認走向 A'(新增 agents + devices 改實體 USB + session 綁代表 device + serial 路由)— ✅ 已裁決同意 +- [x] 與使用者確認前端 FE-A 混合模型(DB 操作用 UUID、路由操作用 serial、serial 空 disable)— ✅ 已裁決同意 +- [ ] 與 backend agent 確認 schema 併入 `database.md`(agents 表 + devices 新欄位 + migration 0005)— 另案,本 ADR 不做 +- [ ] 與 backend agent 確認 WP-C 開工前置:`devicesConnectHandler` 是否 proxy 到 local agent(決定 connect/disconnect 歸 serial 或 UUID) +- [ ] 與 testing agent 確認回歸:既有推論 E2E(`TestE2E_FullFlow_PairingToForward` 等)在 serial 反查加入後仍全綠 + 0005 up/down 對稱測試(130 testcontainers) +- [ ] 成本影響:無新雲端資源(沿用現有 DB / tunnel),主要是 visionA 三 module(backend / local-agent / frontend)開發工時,粗估全量 20–36 人天、僅 task 1 則 3–6 人天 + +--- + +## 7. 一句話總結 + +讀 code 後推翻「`session_tokens.device_id` 單一 FK 是硬傷」的假設——推論路由根本不靠它,真正缺口是「雲端 device.id ↔ local agent 本地 id」從不對齊。故採 **走向 A'**:新增 `agents` 表、`devices` 改為實體 USB 掛 `agent_id`、`session_tokens.device_id` 保留物理 schema 但改綁「agent 代表 device」(避開破壞性 FK migration)、路由統一用 serial(kn_number)並在 local agent 端加 serial→本地 key 雙查(純加法、保護推論)。前端採 **FE-A 混合模型**:DB 操作用 UUID、路由到 local agent 的操作用 serial(serial 值替換 UUID、傳輸位置不動),serial 空的 device 路由類操作 disable。