visionA/docs/autoflow/04-architecture/adr/adr-018-agent-device-model.md
jim800121chen 72544d00ba docs(adr): ADR-018 §8 addendum — C5 實作偏差回填(路由段維持 UUID)
- §5.5 加補註指向、原文一字未動(ADR 不可變原則)
- §8.1 記錄 C5 偏差證據鏈(fetchDevice 純 DB / API 無 GetBySerial /
  帶 serial deep-link 必 404)與最終實作(路由段 UUID、serial 頁內派生、
  入口 disable 全落實);決策本體 FE-A 與 C1–C4/C6–C8 不變
- 裁定來源:wp-c-frontend-serial-routing-review.md(reviewer 獨立驗證)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 08:39:32 +08:00

198 lines
18 KiB
Markdown
Raw Permalink 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]`
> ⚠️ 補註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 三 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。
---
## 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 其餘各條C1C4 / C6C8均維持原決策本補註僅將 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、直接於原條目修正 + 表下加修正註記)。