diff --git a/direct.go b/direct.go index c06ecbf..8387eab 100644 --- a/direct.go +++ b/direct.go @@ -107,6 +107,34 @@ type DirectConfig struct { // LintStrict=軟項也擋(測試/CI);預設 false=硬缺拒收、軟項照送標 quality:low。 // CLI `--strict` 會覆蓋成 true。 LintStrict bool `json:"lint_strict,omitempty"` + + // MachineLabel=這台機器要在雲端顯示成什麼名字(`inkstone/mira#6`,leo 2026-08-18)。 + // 空=用 machine.json 鑄好的可讀 ID(`youlinhsieh@Leo-MBA`)。 + // leo:「他再自己去改『教育部 Leo 的 Mac』」——改的是**這一格**,機器 ID 不動, + // 所以改名不會讓庫裡憑空多出一台機器。 + MachineLabel string `json:"machine_label,omitempty"` + + // machine=解析好的機器身分快取(不落 config 檔:ID 的家是 machine.json, + // 這裡只是這一輪的記憶體副本。makeAccountSubConfig 的 `sub := *c` 會一起複製, + // 所以多帳號同一輪只解析一次、每個帳號送出的值必然一致)。 + machine *MachineIdentity +} + +// machineIdentity 回這台機器的身分(第一次呼叫時解析並鑄檔,之後讀快取)。 +// +// 身分檔跟著 manifest 放(實務上=~/.arcrun-rag/):那是本 daemon 既有的狀態目錄, +// 不另立第二個狀態位置。 +func (c *DirectConfig) machineIdentity() MachineIdentity { + if c.machine != nil { + return *c.machine + } + stateDir := "" // 空=不落檔(沒設 manifest 的臨時 config 不該把身分檔亂寫進工作目錄) + if strings.TrimSpace(c.Manifest) != "" { + stateDir = filepath.Dir(expandHome(c.Manifest)) + } + id := ResolveMachine(stateDir, c.MachineLabel) + c.machine = &id + return id } // librarySlug 把資料夾名轉成合法庫名(A-Za-z0-9_-;中文等非 ASCII 轉為底線分段)。 @@ -1002,10 +1030,13 @@ func drainPendingTakedowns( for oldPath, pageName := range m.PendingTakedowns { pace() res := DirectResult{Type: resultType, Path: oldPath} + mach := cfg.machineIdentity() status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.RemovedWF), map[string]any{ - "page_name": pageName, - "path": oldPath, - "library": cfg.libraryFor(absRoot), + "page_name": pageName, + "path": oldPath, + "library": cfg.libraryFor(absRoot), + "machine": mach.ID, + "machine_label": mach.Label, }) res.HTTPStatus = status if perr != nil { @@ -1457,11 +1488,18 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta if cardIdx > 0 { continue // 概念卡先只落本機 .wiki(品質檢查照跑),上雲等第⑤環 } + // machine/machine_label(`inkstone/mira#6`):與 library 同一個位置、 + // 同一個理由——library 分得開「同一台機器的兩個資料夾」,machine 分得開 + // 「兩台機器的同一個相對路徑」。少送這一維,雲端就只能把兩台的同名檔 + // 當成同一份(先到的被後到的蓋掉,而且是無聲的)。 + mach := cfg.machineIdentity() cardBody := map[string]any{ - "page_name": pageNameOf(ev.Path), - "path": ev.Path, - "card_content": string(cardData), - "library": cfg.libraryFor(absRoot), + "page_name": pageNameOf(ev.Path), + "path": ev.Path, + "card_content": string(cardData), + "library": cfg.libraryFor(absRoot), + "machine": mach.ID, + "machine_label": mach.Label, } if warns := lr.SoftMessages(); len(warns) > 0 { cardBody["quality"] = "low" @@ -1499,11 +1537,18 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta exit = 1 continue } + // 舊的「原文直送雲端萃取」路(rag_ingest_direct)。它與收卡路送同一組欄位, + // 免得日後有人比對兩條路時看到「一條有 machine 一條沒有」而以為是 bug。 + // ⚠️ 雲端這支 workflow 本輪**沒有跟著改**(youlin stage 上根本沒部署它, + // 現役是 rag_ingest_card)——它會忽略這兩個欄位,行為與從前一字不差。 + machDirect := cfg.machineIdentity() status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.IngestWF), map[string]any{ - "page_name": pageNameOf(ev.Path), - "path": ev.Path, - "content": string(content), - "library": cfg.libraryFor(absRoot), + "page_name": pageNameOf(ev.Path), + "path": ev.Path, + "content": string(content), + "library": cfg.libraryFor(absRoot), + "machine": machDirect.ID, + "machine_label": machDirect.Label, }) res.HTTPStatus = status if perr != nil { @@ -1528,9 +1573,17 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta // 下架=POST {page_name, path} 進 rag_takedown_direct(按 page_name 讀 kbdb blocks // 標 deprecated,不碰 R2;獨立於 rag_ingest 的 __CARDS_PREFIX__ 閘——direct 模式檔在 // 資料夾根,會被 rag_ingest 的前綴閘擋掉,故自帶不含前綴閘的下架 workflow)。 + // machine(`inkstone/mira#6`):這條分支歷來只送 {page_name, path} + // (library 是 arcrun-rag#46 只補在 drainPendingTakedowns 那條路上的)。 + // 這裡只補 machine、**不順手補 library**:machine 已足以擋住「A 機器刪檔 + // 連坐殺掉 B 機器同名檔」,而多補一維會改變既有的撤除命中範圍—— + // 那是另一件事,要另外驗(本輪不驗的不做)。 + machRm := cfg.machineIdentity() status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.RemovedWF), map[string]any{ - "page_name": pageNameOf(ev.Path), - "path": ev.Path, + "page_name": pageNameOf(ev.Path), + "path": ev.Path, + "machine": machRm.ID, + "machine_label": machRm.Label, }) res.HTTPStatus = status if perr != nil { diff --git a/inventory.go b/inventory.go index 28bcb86..84451c6 100644 --- a/inventory.go +++ b/inventory.go @@ -270,11 +270,17 @@ func syncInventory(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents, dr if wf == "" { wf = "rag_ingest_card" } + // machine(`inkstone/mira#6`):總覽卡的 path 是**合成**的(inventoryCardPath), + // 只帶 library ⇒ 兩台機器上同名的資料夾會生出一模一樣的鍵,後同步的那台會把 + // 前一台的總覽卡蓋掉。這裡與逐檔卡走同一組欄位,不另開一種。 + mach := cfg.machineIdentity() status, _, err := cfg.postJSON(cfg.triggerURL(wf), map[string]any{ - "page_name": page, - "path": res.Path, - "card_content": card, - "library": lib, + "page_name": page, + "path": res.Path, + "card_content": card, + "library": lib, + "machine": mach.ID, + "machine_label": mach.Label, }) res.HTTPStatus = status if err != nil { diff --git a/machineid.go b/machineid.go new file mode 100644 index 0000000..074b01e --- /dev/null +++ b/machineid.go @@ -0,0 +1,205 @@ +// 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_stale/rag_takedown_direct 的 build_deprecations)。 + ID string `json:"id"` + // Label=人看得懂的稱呼。預設等於 ID;使用者在 config.json 設 `machine_label` 後以它為準。 + Label string `json:"label"` + // Host/User=鑄 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.json/manifest 所在的 ~/.arcrun-rag)。 +// labelOverride=config.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/畫面上出事的字元,並限長。 +// +// 保留 CJK(leo 要打「教育部 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 +} diff --git a/machineid_test.go b/machineid_test.go new file mode 100644 index 0000000..7edd890 --- /dev/null +++ b/machineid_test.go @@ -0,0 +1,126 @@ +package collector + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// 第①層:抓得到就給一個**人看得懂**的名字,且形狀就是 leo 舉的那個例子。 +func TestResolveMachineReadableID(t *testing.T) { + dir := t.TempDir() + got := ResolveMachine(dir, "") + if !strings.Contains(got.ID, "@") { + t.Fatalf("ID 應該是 <使用者>@<主機> 的形狀,got %q", got.ID) + } + if got.Label != got.ID { + t.Fatalf("沒設 machine_label 時顯示名該等於 ID,got label=%q id=%q", got.Label, got.ID) + } + if !got.Minted { + t.Fatalf("第一次呼叫應該是現鑄") + } + if _, err := os.Stat(filepath.Join(dir, machineFileName)); err != nil { + t.Fatalf("身分應該落檔(否則下次重開機就變成另一台機器):%v", err) + } +} + +// 🔴 ID 一鑄就不變:改機器名不可以讓庫裡憑空多出一台機器。 +func TestResolveMachineIDIsStable(t *testing.T) { + dir := t.TempDir() + first := ResolveMachine(dir, "") + second := ResolveMachine(dir, "") + if first.ID != second.ID { + t.Fatalf("同一台機器兩次呼叫 ID 不同:%q vs %q", first.ID, second.ID) + } + if second.Minted { + t.Fatalf("第二次不該重鑄") + } + // 手動把檔案裡的 host 改掉(模擬使用者在 macOS 改了電腦名稱)——ID 仍不該變。 + raw, _ := os.ReadFile(filepath.Join(dir, machineFileName)) + var saved MachineIdentity + _ = json.Unmarshal(raw, &saved) + saved.Host = "改過的名字" + b, _ := json.Marshal(saved) + _ = os.WriteFile(filepath.Join(dir, machineFileName), b, 0o600) + third := ResolveMachine(dir, "") + if third.ID != first.ID { + t.Fatalf("host 變了 ID 就跟著變=那台機器的舊知識會掛到新機器底下:%q vs %q", third.ID, first.ID) + } +} + +// 第②層:使用者改成自己看得懂的稱呼,**只動顯示名**。 +func TestResolveMachineLabelOverride(t *testing.T) { + dir := t.TempDir() + base := ResolveMachine(dir, "") + got := ResolveMachine(dir, "教育部 Leo 的 Mac") + if got.Label != "教育部 Leo 的 Mac" { + t.Fatalf("machine_label 沒生效,got %q", got.Label) + } + if got.ID != base.ID { + t.Fatalf("改顯示名不該動到 ID:%q vs %q", got.ID, base.ID) + } +} + +// 第③層:什麼都抓不到時,**兩台仍必須不同**。 +func TestMintFallbackIsUnique(t *testing.T) { + a := mintFallbackForTest() + b := mintFallbackForTest() + if a == "" || b == "" { + t.Fatalf("後備 ID 不該是空字串(空值會讓兩台被當成同一台)") + } + if a == b { + t.Fatalf("兩次後備 ID 相同=兩台機器會被當成同一台:%q", a) + } + if !strings.HasPrefix(a, unknownMachinePrefix) { + t.Fatalf("後備 ID 該看得出是「沒認出來」,got %q", a) + } +} + +func mintFallbackForTest() string { return unknownMachinePrefix + randomMachineSuffix() } + +// 顯示名清洗:保留 CJK(leo 要打中文),砍掉會把 metadata_json 字串提前結束的字元。 +func TestSanitizeMachinePart(t *testing.T) { + cases := map[string]string{ + "教育部 Leo 的 Mac": "教育部 Leo 的 Mac", + "a\"b": "a-b", + "a\\b": "a-b", + "a/b": "a-b", + " Leo-MBA ": "Leo-MBA", + "a\nb": "ab", + } + for in, want := range cases { + if got := sanitizeMachinePart(in); got != want { + t.Errorf("sanitizeMachinePart(%q) = %q, want %q", in, got, want) + } + } + if got := sanitizeMachinePart(strings.Repeat("長", 200)); len([]rune(got)) != 64 { + t.Errorf("超長名字該截到 64 rune,got %d", len([]rune(got))) + } +} + +// 主機名的網路後綴不該進畫面:leo 要看的是 `Leo-MBA`,不是 `Leo-MBA.local`。 +func TestShortHostnameTrimsSuffix(t *testing.T) { + h := shortHostname() + if strings.Contains(h, ".") { + t.Errorf("shortHostname 應該只留第一段,got %q", h) + } +} + +// config 上的 machine_label 要能一路走到 DirectConfig.machineIdentity()(多帳號共用同一份)。 +func TestDirectConfigMachineIdentity(t *testing.T) { + dir := t.TempDir() + cfg := &DirectConfig{Manifest: filepath.Join(dir, "manifest.json"), MachineLabel: "教育部 Leo 的 Mac"} + got := cfg.machineIdentity() + if got.Label != "教育部 Leo 的 Mac" { + t.Fatalf("config 的 machine_label 沒生效,got %q", got.Label) + } + sub := cfg.makeAccountSubConfig(AccountConfig{Namespace: "ns", CypherURL: "https://example.invalid"}) + if sub.machineIdentity().ID != got.ID { + t.Fatalf("每個帳號送的機器身分必須一致:%q vs %q", sub.machineIdentity().ID, got.ID) + } + if _, err := os.Stat(filepath.Join(dir, machineFileName)); err != nil { + t.Fatalf("身分檔該落在 manifest 同一個目錄:%v", err) + } +}