Files
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

127 lines
4.5 KiB
Go
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.
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 時顯示名該等於 IDgot 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 runegot %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)
}
}