visionA/docs/autoflow/04-architecture/adr/adr-018-agent-device-model.md
jim800121chen 276910854e docs(adr): ADR-018 一 agent 多 device 模型 + serial 路由(走向 A')
個人設備管理資料模型決策,使用者裁決 Accepted。

- 走向 A':新增 agents 表 + devices 改實體 USB 掛 agent_id +
  session_tokens.device_id 保留物理 schema 改綁代表 device(避開破壞性
  FK migration)+ 路由統一用 serial(kn_number) + local agent serial 反查
  雙查(純加法、保護剛做完的推論)
- 前端 FE-A 混合:DB 操作用 UUID、路由到 local agent 操作用 serial、
  serial 空的 device 路由操作 disable
- 關鍵論證:推論路由不經 session_tokens.device_id(proxy pickActiveSessionToken
  按 userID),單一 FK 非硬傷;真缺口是 UUID↔local key 未對齊
- migration 0004(序號、無 DDL 可先交付)+ 0005(agents 模型)

替代方案(mapping 版 A/B、FE-B)與否決理由、R-Route 查證結論已併入 ADR。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 08:39:27 +08:00

168 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 唯一鍵(多租戶複合鍵留 Phase1Q5 取消註冊 = 真實體刪除 + 清 `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 UUIDlocal 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. **路由統一用 serialkn_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 uploadimage/video/batch
因此原設計「用 serial 當 **path** 識別」只對一半。**正確落地 = 前端把 device 識別「值來源」從 `device.id`UUID換成 `device.serialNumber`傳輸位置path vs body完全不動**。且採 **FE-A 混合模型**
| 操作類別 | 用哪個識別值 | 理由 |
|---------|------------|------|
| **純雲端 DB 操作**(列表 / 詳情 fetchDevice / unpair | **UUID** | 在 api-server 直接查 DBUUID 是主鍵最自然;且 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 targetdevices→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 | 用 serialkn_number建立 | **是**task 1 只填 serial、agents 表可後補) | 低 | ✅ 採用 |
**前端識別模型的替代方案**
| 方案 | 前端改動 | 排除原因 |
|------|---------|---------|
| **FE-A採用混合**DB 操作用 UUID、路由操作用 serial | 識別值來源分流,語意最正確 | ✅ 採用 |
| FE-B 全部用 serial | 連 unpair / 詳情都用 serialapi-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 一行都不用改(路由機制天然支援多 USBlocal agent `sessions` map 的 key 不改;`GetDevice` 雙查向下相容。
### 4.2 負面(接受的取捨)
- **多一張 `agents` 表 + 一次資料遷移**現有「device=連線」的舊 row 要拆成「1 agent + N devicerepresentative遷移邏輯見 §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 查證已列出逐檔清單 C1C8
### 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 目標 ERA' 模型)
```
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_numberkn_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
- **0005agents 模型)**`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=NULLNULL 互不相等、天然共存);假序號寫 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 + normalizeC2 `workspace-client.tsx` camera/media/inference 識別改 serial + disableC3 `use-inference-stream.ts` 註解C4 `lib/media.ts` 註解C5 `workspace/[deviceId]` 連結改帶 serialC6 `device-card.tsx` 工作區按鈕加 `hasSerial`C7 `device-detail-client.tsx` 工作區用 serial詳情頁本身 fetchDevice 維持 UUIDC8 flashF8 接時用 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 三 modulebackend / local-agent / frontend開發工時粗估全量 2036 人天、僅 task 1 則 36 人天
---
## 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、路由統一用 serialkn_number並在 local agent 端加 serial→本地 key 雙查(純加法、保護推論)。前端採 **FE-A 混合模型**DB 操作用 UUID、路由到 local agent 的操作用 serialserial 值替換 UUID、傳輸位置不動serial 空的 device 路由類操作 disable。