# 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]`。 > ⚠️ 補註(2026-07-11):上列 C5「連結改帶 serial」一句於 WP-C 實作時證實與 §2.2 FE-A 自相矛盾,實作偏差已裁定接受——路由段維持 UUID、serial 頁內派生。詳見 §8 補註(本段原文依 ADR 不可變原則保留、不改寫)。 --- ## 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。 --- ## 8. 補註(Addendum) > ADR 為不可變決策紀錄:以下補註只增不刪,§1–§7 原文一字未動。 ### 8.1 C5 實作偏差回填(2026-07-10 實作定案、2026-07-11 回填) **偏差內容**:§5.5 原句「C5 `workspace/[deviceId]` 連結改帶 serial」於 WP-C 前端實作時證實與本 ADR §2.2 FE-A 混合模型**自相矛盾**: 1. workspace 進頁第一件事是 `fetchDevice(路由段值)`,屬「純雲端 DB 操作」——依 FE-A 分流表本應歸 **UUID** 組。 2. api-server 只有 `GET /devices/:id` 一個讀取端點、handler 直接按 DB 主鍵(UUID)查(`visionA-backend/internal/api/devices.go:26-42`),**不存在任何 GetBySerial 端點**。 3. 路由段若帶 serial:直接輸入 URL / 重新整理 / 書籤進入 workspace → `fetchDevice(serial)` → 404 → 整頁卡占位態。要救就得 api-server 加 serial 查 DB——正是本 ADR §3 否決 FE-B 時列出的理由。 R-Route 查證報告 §3.2 第 4 點當時已標注此張力,但 §4 C5 與本 ADR §5.5 C5 的行文未回頭同步,屬**文件內部殘留矛盾、非實作漏做**。 **最終實作(經 reviewer 獨立驗證、裁定偏差成立並接受)**: - workspace 路由段 `[deviceId]` **維持 UUID**(`device-card.tsx` / `device-detail-client.tsx` / `workspace/page.tsx` 連結仍帶 `device.id`)。 - serial 由頁內 `selectedDevice.serialNumber` 派生,供 camera / media / inference WS 等路由類操作使用。FE-A 精神(「路由到 local agent 的操作識別值 = serial」)100% 達成——路由段只是「取 device 資料的 key」、本身不路由到 local agent。 - C5 的「serial 空時工作區入口 disable + tooltip」行為全數落實,另加頁內 no-serial banner。 **決策本體不變**:§2.2 FE-A 混合模型、§5.5 其餘各條(C1–C4 / C6–C8)均維持原決策;本補註僅將 C5 中「連結 href 改帶 serial」一句更正為「路由段維持 UUID、serial 頁內派生」。 **參照**: - 裁定證據鏈:`.autoflow/05-implementation/review/wp-c-frontend-serial-routing-review.md` §二(C5 偏差獨立驗證)與 Minor #2(本回填的來源指令)。 - 同步修正文件:`.autoflow/04-architecture/personal-device-mgmt-R-Route-frontend-verification.md` §4 C5(該檔非 ADR、直接於原條目修正 + 表下加修正註記)。