Files
arcrun-collector/machineid.go
T
claude-code 395cad8f12 feat(collector): 每一則知識都答得出「原稿在哪一台機器上」(inkstone/mira#6)
現況只缺這一格:雲端的麵包屑已經有資料夾多層路徑、檔名、片段錨點
(kb://RFP/design.md#0),唯獨答不出「哪一台機器」。

leo 2026-08-18 拍板的三層,由強到弱:
① 抓得到就用真實可讀的名字 → `<使用者>@<主機名>`(實跑:youlinhsieh@Leo-MBA)
② 使用者要能改成自己看得懂的稱呼 → config.json 的 machine_label(實跑:教育部 Leo 的 Mac)
③ 什麼都抓不到也要保證兩台不同 → unknown@<隨機 8 hex>,持久化在 machine.json

做法照既有的 library 走,不新增第二種:
- daemon 逐筆隨 payload 送 machine/machine_label(收卡、總覽卡、下架、舊直送四處)
- 雲端寫進 block 的 metadata_json,與 source/source_path/library 並排
- 三元組寫進 machine slot(slot 由 matrix/arcrun 的 seed 補,見該 repo 分支)
- upsert/下架的比對規則照抄 arcrun-rag#46 對 library 的處置:**兩邊都有值才收緊**
  ⇒ 既有資料(沒有這一格的)行為一字不變,不會變兩份、也不會撤不掉

🔴 machine **不混進 source_uri**:portal 的 srcLocalPath() 直接把 kb:// 之後整段當本機
   相對路徑用,混進去會吐出不存在的路徑,而且舊資料的第一段會被誤讀成機器名
   ——那就是「憑空長出假機器」。分成獨立欄位,舊資料就誠實地空著。

ID 一鑄就不再重算(machine.json):user@host 會變,重算會讓同一台機器的舊知識
突然掛到「新機器」底下。改名走 machine_label,不碰 ID。

驗過(youlin stage,實測輸出見 inkstone/mira#6 回報):
- 真 daemon 跑 direct --once → 每個 block 的 metadata 都有
  machine=youlinhsieh@Leo-MBA、machine_label=教育部 Leo 的 Mac
- 同一相對路徑、兩台機器 → block 各留一份、三元組 4 筆並存,沒有互相刪掉
- 既有資料(無 machine)照樣被同一台機器 upsert 掉,沒有變兩份
- workflows/tests/machine-scope.test.mjs 13 passed;takedown-scope 8 passed;isdoc 21 passed
- collector go test ./... 全綠
2026-08-18 16:50:23 +08:00

206 lines
8.8 KiB
Go
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.
// machineid.go — 這台機器是誰(`inkstone/mira#6`leo 2026-08-18 拍板)。
//
// 🔴 它解的缺口只有一格:
//
// 雲端每一則知識都答得出「原稿在哪個資料夾、哪個檔、第幾段」
// `kb://RFP/design.md#0`),**唯獨答不出「在哪一台機器上」**。
// leo 原話:「公用庫有多個人在維護,原稿要跟不同的人拿⋯⋯掛上資料夾至少先給一個 ID,
// 例如 `youlinhsieh@Leo-MBA`,他再自己去改『教育部 Leo 的 Mac』。」
// ⇒ 這個值的用途**不是技術識別,是「知道該去找誰拿原稿」**。
//
// 三層,由強到弱(leo 定的最低要求:「預編的辨識資料就看能抓到什麼,
// 如果什麼都抓不到,**至少知道 A 電腦和 B 電腦不同**」):
//
// ① 抓得到 ⇒ 用真實可讀的名字:`<使用者>@<主機名>`(=leo 舉的那個例子本身)
// ② 使用者要改 ⇒ config.json 的 `machine_label` 覆蓋顯示名(ID 不動)
// ③ 什麼都抓不到 ⇒ `unknown@<隨機 8 hex>`**保證兩台不會被當成同一台**
//
// 🔴 為什麼 ID 一鑄就不再重算(`machine.json`):
//
// `user@host` 是**會變**的東西——leo 在 macOS 改一次電腦名稱,重算就會得到新 ID,
// 那台機器上的舊知識會突然掛到一個「新機器」底下,樹上憑空多一支。
// 所以第一次算完就寫進 `<設定目錄>/machine.json`,之後**只讀不算**。
// 要改的是顯示名(第②層),那條路不碰 ID。
//
// 🔴 為什麼 ID/顯示名分兩個檔案位置:
//
// `machine.json`=機器自己鑄的身分(使用者不必知道它存在);
// `config.json` 的 `machine_label`**使用者設定**,而使用者設定在本專案一律住 config
// direct.go:「設定只走檔案/環境,不落 code」)。一種設定一個家,不開第二條路。
//
// 與 `library` 是同一個形狀(照既有寫法走,不新增第二種做法):
// `library` 是「這份原稿在哪個**資料夾**」,`machine` 是「這份原稿在哪台**機器**」——
// 兩者都逐筆隨 payload 送上雲、都寫進 metadata_json/三元組 slot、
// 都在下架比對時當「兩邊都有值才收緊」的那一維(arcrun-rag#46 立的規則)。
package collector
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"os"
"os/user"
"path/filepath"
"strings"
)
// MachineIdentity 是這台機器對雲端的自我介紹。
//
// ID 進資料(比對鍵,永不變);Label 給人看(可改,只影響顯示)。
type MachineIdentity struct {
// ID=鑄好就不變的機器識別。這是**比對鍵**:同一份相對路徑來自兩台機器時,
// 只有它分得開(見 rag_ingest_card 的 pick_stalerag_takedown_direct 的 build_deprecations)。
ID string `json:"id"`
// Label=人看得懂的稱呼。預設等於 ID;使用者在 config.json 設 `machine_label` 後以它為準。
Label string `json:"label"`
// HostUser=鑄 ID 當時**實際抓到**什麼(抓不到就是空字串)。
// 留著是為了誠實:日後要判斷「這個 ID 是真的認出來的、還是隨機湊的」,看這兩格。
Host string `json:"host,omitempty"`
User string `json:"user,omitempty"`
// Minted=這一次呼叫是否**現鑄**(true)還是讀既有的(false)。不落檔,只給呼叫端記錄用。
Minted bool `json:"-"`
}
// machineFileName 是身分檔的檔名(住在 config/manifest 同一個目錄)。
const machineFileName = "machine.json"
// unknownMachinePrefix=三層裡的第③層(什麼都抓不到)。前綴保留可讀性:
// 使用者在 portal 看到 `unknown@3f2a1b9c` 也知道「這台沒認出來」,而不是看到一串亂碼。
const unknownMachinePrefix = "unknown@"
// ResolveMachine 回答「我是哪一台」。
//
// stateDir=身分檔要住的目錄(實務上=config.jsonmanifest 所在的 ~/.arcrun-rag)。
// labelOverrideconfig.json 的 machine_label(空字串=使用者沒設)。
//
// 永不回錯誤:認不出來就走第③層(隨機但唯一),**寧可誠實地給一個「不知道是誰」的唯一 ID,
// 也不要讓兩台機器共用一個空值**——空值會讓雲端把兩台的東西併成一份,那正是本題要防的。
// 寫檔失敗也不擋(唯讀目錄/權限問題):這一輪照跑,只是下一輪會重鑄 ID。
// stateDir 給空字串=**不落檔**(只在記憶體裡鑄一個)。這是給「還沒有狀態目錄」的
// 呼叫端(測試、--dry-run 的臨時 config)用的:與其把 machine.json 亂寫進當下的工作目錄,
// 不如不寫——身分檔的家只有一個,就是 daemon 的狀態目錄。
func ResolveMachine(stateDir, labelOverride string) MachineIdentity {
persist := strings.TrimSpace(stateDir) != ""
path := filepath.Join(stateDir, machineFileName)
id := MachineIdentity{}
// 既有身分優先——ID 一鑄就不再重算(改機器名不該產生第二台機器)。
if b, err := os.ReadFile(path); err == nil && persist {
var saved MachineIdentity
if json.Unmarshal(b, &saved) == nil && strings.TrimSpace(saved.ID) != "" {
id = saved
}
}
if strings.TrimSpace(id.ID) == "" {
id = mintMachineIdentity()
id.Minted = true
// 寫回:值本身沒有機敏性(一個使用者名+主機名),但它必須跨重啟保持一致。
if b, err := json.MarshalIndent(id, "", " "); err == nil && persist {
_ = os.MkdirAll(stateDir, 0o755)
_ = os.WriteFile(path, append(b, '\n'), 0o600)
}
}
// 顯示名:使用者設定 > 鑄的時候存下的 > ID 本身。
if l := strings.TrimSpace(labelOverride); l != "" {
id.Label = sanitizeMachinePart(l)
}
if strings.TrimSpace(id.Label) == "" {
id.Label = id.ID
}
return id
}
// mintMachineIdentity 鑄一個新身分(只在沒有既有身分時走這裡)。
func mintMachineIdentity() MachineIdentity {
host := sanitizeMachinePart(shortHostname())
who := sanitizeMachinePart(currentUserName())
switch {
case who != "" && host != "":
// 第①層:leo 舉的那個形狀。
return MachineIdentity{ID: who + "@" + host, Label: who + "@" + host, Host: host, User: who}
case host != "":
return MachineIdentity{ID: "unknown@" + host, Label: "unknown@" + host, Host: host}
case who != "":
// 有人沒機器名:仍要保證兩台不同 ⇒ 補隨機尾碼(同一個使用者名可能出現在多台機器上)。
id := who + "@" + randomMachineSuffix()
return MachineIdentity{ID: id, Label: id, User: who}
default:
// 第③層:什麼都抓不到。唯一性是唯一還撐得住的保證。
id := unknownMachinePrefix + randomMachineSuffix()
return MachineIdentity{ID: id, Label: id}
}
}
// shortHostname 取主機名的第一段:`Leo-MBA.local` → `Leo-MBA`。
// macOS 的 os.Hostname() 常帶 `.local``.lan`,那是網路後綴不是機器名,
// 留著會讓 leo 在畫面上看到 `youlinhsieh@Leo-MBA.local`——比他要的難讀。
func shortHostname() string {
h, err := os.Hostname()
if err != nil {
return ""
}
h = strings.TrimSpace(h)
if i := strings.Index(h, "."); i > 0 {
h = h[:i]
}
// 認不出來的預設值不算「抓到了」——`localhost` 每台都一樣,用它當 ID 等於把所有機器
// 合成一台,正是本題要防的事。
if strings.EqualFold(h, "localhost") {
return ""
}
return h
}
// currentUserName 取登入帳號(不是全名)。Windows 上 os/user 會回 `DOMAIN\user`,取後半。
func currentUserName() string {
u, err := user.Current()
if err != nil || u == nil {
return ""
}
name := strings.TrimSpace(u.Username)
if i := strings.LastIndexAny(name, `\/`); i >= 0 {
name = name[i+1:]
}
return name
}
// randomMachineSuffix 產生 8 個 hex 字元。取不到亂數時退回 os.Getpid()+時間也不夠穩,
// 所以直接讓它空——呼叫端會得到空字串,那時 ID 只剩前綴,**這種情況要看得出來**,
// 不要用一個看起來正常的假值蓋過去。
func randomMachineSuffix() string {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
return ""
}
return hex.EncodeToString(b[:])
}
// sanitizeMachinePart 清掉會讓值在 JSON/URL/畫面上出事的字元,並限長。
//
// 保留 CJKleo 要打「教育部 Leo 的 Mac」),只砍控制字元、引號、斜線與換行——
// 這個值會進 metadata_json 字串與三元組 slot,不該有能提前結束字串的字元。
func sanitizeMachinePart(s string) string {
s = strings.TrimSpace(s)
var b strings.Builder
for _, r := range s {
switch {
case r < 0x20 || r == 0x7f: // 控制字元
continue
case r == '"' || r == '\\' || r == '/':
b.WriteRune('-')
default:
b.WriteRune(r)
}
}
out := strings.TrimSpace(b.String())
// 限長:機器名不該是一篇文章。以 rune 計,避免把 UTF-8 切壞。
rs := []rune(out)
if len(rs) > 64 {
out = strings.TrimSpace(string(rs[:64]))
}
return out
}