395cad8f12
現況只缺這一格:雲端的麵包屑已經有資料夾多層路徑、檔名、片段錨點 (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 ./... 全綠
127 lines
4.5 KiB
Go
127 lines
4.5 KiB
Go
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)
|
||
}
|
||
}
|