From 30a087c14dd8d07819d971ebed523cab7ac263c0 Mon Sep 17 00:00:00 2001 From: richblack Date: Sat, 29 Aug 2026 11:38:40 +0800 Subject: [PATCH] =?UTF-8?q?sync:=20collector/=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E8=87=AA=20inkstone/arcrun-rag@7ade413=EF=BC=88=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E5=B0=8F=E5=B9=AB=E6=89=8B=200.18.49=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 + cmd/arcrun-app/.version-source-files.json | 11 +- cmd/arcrun-app/.version-source.json | 3 +- direct.go | 7 +- foldertree.go | 41 +++++ foldertree_machine_test.go | 173 ++++++++++++++++++++++ 6 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 foldertree_machine_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 202ff18..46e17a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,12 @@ --- +## 0.18.49(2026-08-29) + +- **雲端的知識庫清單看得出每一個資料夾是從哪一台電腦同步上去的**:以前小幫手回報資料夾結構時沒有附上自己是誰,所以你在雲端看到的每一個資料夾都掛在「未知來源」底下——幾年下來累積的資料夾全擠在同一個位置,看起來就像別人的東西。現在每一次回報都帶著這台電腦的名字(你在設定裡取過名就用你取的)。 +- 改名之後不會多出一台電腦,也不必等下一次改檔才生效:名字一換,下一輪就會把新名字送上去。 +- ⚠️ 這一格要雲端也換新了才看得到;小幫手這邊先把名字送出去。收什麼檔、怎麼整理完全沒改。 + ## 0.18.48(2026-08-28) - 🔴 **資料夾那一列不再是一整排字**:以前每個資料夾後面掛著「補送中」「資料夾結構」「移除」三個詞,底下再壓一句長說明,七個資料夾就是七段文字。現在那一列只剩三個圖示,同步好了就是一個打勾,滑過去才會告訴你細節。 diff --git a/cmd/arcrun-app/.version-source-files.json b/cmd/arcrun-app/.version-source-files.json index be34a4f..af18b3f 100644 --- a/cmd/arcrun-app/.version-source-files.json +++ b/cmd/arcrun-app/.version-source-files.json @@ -1,10 +1,10 @@ { "_algo": 4, - "version": "0.18.48", - "fingerprint": "a440a58a629f960c", + "version": "0.18.49", + "fingerprint": "7cb86e646aeaa057", "files": { ".gitignore": "c7a98c44ecc882a174720e43dabd4c04d7cf2503586426dcccaa213d5a27df38", - "CHANGELOG.md": "51b85dc1e96ce190a8652df8a696bd664229b1c5bcb6cf695ced905dc5956d1b", + "CHANGELOG.md": "19af5eaa6b67bcd922fd07a53a42ce75c52bec23ad2c161323e925d5860e9031", "DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d", "README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f", "cardloop_test.go": "fdf09b62650c328f5d8075d964a0ca2dada5f8d9a271202768472998b45e31a8", @@ -124,7 +124,7 @@ "convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394", "convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18", "convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b", - "direct.go": "ba19086aa177884405c038a8841cd5b6b2ef2bf2043d18a708ff7e6c6c9ca45f", + "direct.go": "7a3d2355b5e7442c55b99ce3896e9ba9acee875604b83f2673c1240aed57bbcf", "direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4", "direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e", "direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c", @@ -144,9 +144,10 @@ "folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734", "folderindex_test.go": "a5319eb1e92473cb5c32d3beddb7889997556227824de300dd39953b43beab70", "foldershape.go": "b1b237f5674674efdc9bdea849b63c571a014e64bec33af42fb7c4769fa7582b", - "foldertree.go": "c12461137d6c177db8e1e2f5c7446bcbd6bfd567f044cc07e11e883cc71892dd", + "foldertree.go": "fb86c8d869a476d77d548e9c9a7c14be51c76fa1400a6497016b705ff9c4ce13", "foldertree_live_test.go": "ace96ffdf7670a69761973e810fb6548c7036d2768141e09db515313df1e9a50", "foldertree_local_test.go": "ce919dc2a21ec96716e3d43a7f493367346e212243b4ab22c2d960fc26408f3a", + "foldertree_machine_test.go": "f5dc73afc261b0fb0298f27962628e8538abc704540b52d66e13bf85600a59dd", "foldertree_publish_test.go": "104b26ffac1fc78a4c9b061aba3e2167c7d71ea469073537c3d86e3896debdea", "foldertree_test.go": "f8b8cdf1ef9d7d1c80cabb086fc9b4f2162b91a47d6d78bde2632b07672c0b88", "foldertree_traversal_test.go": "b9a3ce5c784b7601e628b1299596b3572ca787e654cd2ee20b973d053bab84aa", diff --git a/cmd/arcrun-app/.version-source.json b/cmd/arcrun-app/.version-source.json index 5e25663..6803672 100644 --- a/cmd/arcrun-app/.version-source.json +++ b/cmd/arcrun-app/.version-source.json @@ -18,5 +18,6 @@ "0.18.45": "dc05513103f93306", "0.18.46": "1325784830f592ff", "0.18.47": "0decb0810da46a3f", - "0.18.48": "a440a58a629f960c" + "0.18.48": "a440a58a629f960c", + "0.18.49": "7cb86e646aeaa057" } diff --git a/direct.go b/direct.go index ed3b240..0d55e65 100644 --- a/direct.go +++ b/direct.go @@ -1542,8 +1542,13 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta // payload.AllExcludedDirs/plan,而從 Scan() 到這裡之間**沒有任何東西動過 m.Entries** //(動它的是下面「removed 暫時放回」那段,本來就在原位置之後)。 // 送上雲端那一發(syncFolderTree)**維持在原來的位置**,用的就是這一棵。 + // + // StampMachine(`inkstone/Arcrun#180`):蓋上「這棵樹是哪一台機器算的」。蓋在這裡 + // ——build 之後、Publish 與 sync 之前——所以**本機快照與上雲酬載必然是同一份身分**。 + // 卡片那條路(folderindex/inventory/sourcerepair)用的是同一個 cfg.machineIdentity(), + // 同一輪只解析一次,兩條路送上去的值必然相同。 tree := BuildFolderTree(absRoot, cfg.libraryFor(absRoot), payload.DirStats, m.Entries, - payload.AllExcludedDirs, plan, runNow) + payload.AllExcludedDirs, plan, runNow).StampMachine(cfg.machineIdentity()) if !dryRun { PublishFolderTreeNow(cfg.Manifest, root, tree, runNow) } diff --git a/foldertree.go b/foldertree.go index 6110e96..76fccb8 100644 --- a/foldertree.go +++ b/foldertree.go @@ -149,6 +149,41 @@ type FolderTree struct { Truncated bool `json:"truncated,omitempty"` // 節點超過上限,畫面要講出來 TotalNodes int `json:"total_nodes"` // 截斷前的真實節點數 GeneratedAt int64 `json:"generated_at"` + + // Machine/MachineLabel=**這棵樹是哪一台機器報上來的**(`inkstone/Arcrun#180`)。 + // + // 🔴 為什麼非有不可:雲端的庫清單是「總庫 → 機器 → 資料夾」三層。樹的酬載裡沒有 + // 這一格時,**每一個庫都只能掛在「未知來源」底下** ⇒ leo 2026-08-28 看到 16 個庫 + // 全擠在同一個未知節點下,第一反應是「這是把別的帳號同步的資料夾外洩了?」。 + // 那個驚嚇是畫面造成的,而畫面之所以說不出來,是因為**上行酬載根本沒送**。 + // + // 🔴 **不是新發明的東西**:機器身分早就存在(`machine.json`/`ResolveMachine`), + // 卡片那條路也早就在送了(`folderindex.go`/`inventory.go`/`sourcerepair.go` + // 的 `"machine"`/`"machine_label"` 兩欄)。這裡**照那條路走**,欄名一字不差, + // 收端才不必為了樹另認一組欄位。 + // + // Machine=比對鍵(鑄好不變);MachineLabel=顯示名(使用者可在 config 改)。 + // 兩格分開的理由見 machineid.go:改名不該讓庫裡憑空多出一台機器。 + // + // 🔴 這兩格**進 Hash()**(不像 GeneratedAt 那樣被剔掉):使用者改了 machine_label + // 之後,雲端要看得到新名字 ⇒ 內容雜湊必須跟著變,否則冪等閘會讓它永遠不再送。 + Machine string `json:"machine,omitempty"` + MachineLabel string `json:"machine_label,omitempty"` +} + +// StampMachine 蓋上「這棵樹是哪一台機器算的」,回一份新的樹(不改原件)。 +// +// 🔴 為什麼是「蓋章」而不是塞進 BuildFolderTree 的參數:BuildFolderTree 是純函式, +// 吃的全是「這個資料夾長什麼樣」的事實;機器身分是**這台電腦是誰**,是另一回事。 +// 分開之後,樹的既有測試不必為了機器身分多餵一份假身分。 +// +// 🔴 為什麼要蓋在樹上、而不是送出時才從 cfg 拿:本機快照(folder-trees.json, +// 桌面小幫手讀的那份)與上雲的酬載**是同一個 FolderTree**。蓋在樹上,兩邊必然一致; +// 送出時才拿,本機那份就永遠是空的——而「本機那份有沒有 machine」正是這次的檢查點。 +func (t FolderTree) StampMachine(m MachineIdentity) FolderTree { + t.Machine = m.ID + t.MachineLabel = m.Label + return t } // BuildFolderTree 把「走訪時數出來的分母」與「manifest 現況的分子」合成一棵樹。 @@ -354,6 +389,12 @@ func syncFolderTree(cfg *DirectConfig, absRoot string, m *Manifest, tree FolderT "generated_at": tree.GeneratedAt, "sync_token": h, "nodes": tree.Nodes, + // 🔴 機器身分(`inkstone/Arcrun#180`):欄名與卡片那條路一字不差 + // (`folderindex.go`/`inventory.go`/`sourcerepair.go`),收端不必另認一組。 + // 值取自 `tree`(已由 StampMachine 蓋章)而不是這裡再問一次 cfg—— + // 本機快照與上雲酬載共用同一個 FolderTree,一個來源就不會漂。 + "machine": tree.Machine, + "machine_label": tree.MachineLabel, } status, _, err := cfg.postJSON(stepFolderTree, cfg.folderTreeURL(), body) res.HTTPStatus = status diff --git a/foldertree_machine_test.go b/foldertree_machine_test.go new file mode 100644 index 0000000..72b8460 --- /dev/null +++ b/foldertree_machine_test.go @@ -0,0 +1,173 @@ +package collector + +// foldertree_machine_test.go — 資料夾樹要說得出「**是哪一台機器報上來的**」 +//(`inkstone/Arcrun#180`)。 +// +// 這張票的現象是 leo 2026-08-28 的原話: +// +// 「youlin 從桌面同步 4 個資料夾,雲端顯示 16 個,**這是把別的帳號同步的資料夾外洩了?**」 +// +// 實查後不是外洩(那 17 個庫全是他自己歷史上同步過的),但**畫面說不出來**: +// 雲端的庫清單是「總庫 → 機器 → 資料夾」三層,而樹的上行酬載裡沒有機器身分 +// ⇒ 每一個庫都只能掛在「未知來源」底下 ⇒ 看起來就像別人的東西。 +// +// 🔴 本檔守的是「**送出去的東西身上有沒有這一格**」,不是畫面長怎樣(那在 matrix/arcrun)。 +// 三個檢查點,各對應一種會靜默壞掉的方式: +// +// ① 酬載欄名要與卡片那條路一字不差 —— 收端不必為了樹另認一組欄位 +// ② 改名要算成「內容變了」 —— 否則冪等閘會讓新名字永遠送不出去 +// ③ 真的跑一輪,兩邊都要有 —— 有人在別處新增 BuildFolderTree 呼叫卻忘了蓋章時,這條會紅 + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" +) + +// ① 酬載欄名:與 folderindex.go/inventory.go/sourcerepair.go 送卡片時完全相同。 +func TestSyncFolderTree酬載帶得出機器身分(t *testing.T) { + var got map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &got) + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + })) + defer srv.Close() + + root := t.TempDir() + mustWrite(t, filepath.Join(root, "note.md"), "# note") + tree := buildTreeFromDisk(t, root).StampMachine(MachineIdentity{ + ID: "youlinhsieh@Leo-MBA", + Label: "教育部 Leo 的 Mac", + }) + + cfg := &DirectConfig{CypherURL: srv.URL, APIKey: "demo"} + m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}} + res := syncFolderTree(cfg, root, m, tree, false, time.Unix(1786900000, 0)) + if res == nil || res.Status != "ingested" { + t.Fatalf("應送達:%+v", res) + } + + // 🔴 欄名寫死在這裡是刻意的:收端(cypher-executor 的 /portal/daemon/folder-tree) + // 認的就是這兩個字串。改欄名=改協定,這條測試就是那道閘。 + if got["machine"] != "youlinhsieh@Leo-MBA" { + t.Errorf("酬載的 machine(比對鍵)不對:%v", got["machine"]) + } + if got["machine_label"] != "教育部 Leo 的 Mac" { + t.Errorf("酬載的 machine_label(顯示名)不對:%v", got["machine_label"]) + } + // 比對鍵與顯示名**必須是兩格**:只送一格的話,使用者改名就等於雲端多出一台機器。 + if got["machine"] == got["machine_label"] { + t.Error("改過名的機器,比對鍵與顯示名不該相同——有一格被另一格頂替了") + } +} + +// ② 改名要算成「內容變了」。 +// +// 這是本檔最容易被忽略的一格:`Hash()` 是冪等閘的唯一判準,機器身分若沒進雜湊, +// 使用者在 config.json 改了 `machine_label` 之後,樹的內容沒動 ⇒ 永遠不再送 +// ⇒ **雲端會一直顯示舊名字,而且沒有任何機制會發現**。 +func TestFolderTreeHash把機器改名算成內容變了(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "note.md"), "# note") + base := buildTreeFromDisk(t, root) + + before := base.StampMachine(MachineIdentity{ID: "youlinhsieh@Leo-MBA", Label: "youlinhsieh@Leo-MBA"}) + after := base.StampMachine(MachineIdentity{ID: "youlinhsieh@Leo-MBA", Label: "教育部 Leo 的 Mac"}) + + if before.Hash() == after.Hash() { + t.Error("改了顯示名雜湊卻沒變——冪等閘會讓新名字永遠送不上去") + } + // 沒蓋章的樹與蓋了章的樹也必須不同,否則升級到本版的機器不會補送一次。 + if base.Hash() == before.Hash() { + t.Error("蓋上機器身分之後雜湊沒變——舊機器升級後不會補送,雲端永遠是「未知來源」") + } + // 但時間仍然不進雜湊(既有慣例,不准被這次改動弄壞)。 + later := before + later.GeneratedAt = before.GeneratedAt + 999 + if later.Hash() != before.Hash() { + t.Error("時間跑進雜湊了——那等於沒有冪等,每輪都會重送整棵樹") + } +} + +// ③ 真的跑一輪:本機快照與上雲酬載**是同一份身分**。 +// +// 🔴 這條守的是「有人忘了蓋章」。`StampMachine` 蓋在 direct.go 的 BuildFolderTree +// 呼叫點上;日後若有人在別處另起一個呼叫、或把蓋章那一行刪掉, +// 前兩條測試(各自手動蓋章)照樣會綠,只有這條會紅。 +func TestRunDirectOnce的樹一路帶著機器身分(t *testing.T) { + var treeBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") { + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &treeBody) + } + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + })) + defer srv.Close() + + base := t.TempDir() + root := filepath.Join(base, "我的知識庫") + mustWrite(t, filepath.Join(root, "報銷規則.md"), "# 報銷規則\n內容") + + orig := fetchCloudVersion + fetchCloudVersion = func(string) (string, bool) { return "stub", true } + defer func() { fetchCloudVersion = orig }() + + manifest := filepath.Join(base, "manifest.json") + cfg := &DirectConfig{ + Manifest: manifest, + MachineLabel: "教育部 Leo 的 Mac", // 使用者改過名 ⇒ 比對鍵與顯示名不同,兩格才分得出來 + Accounts: []AccountConfig{{CypherURL: srv.URL, Namespace: "ns", WatchFolders: []string{root}}}, + MaxRemoved: DefaultMaxRemovedRatio, + ExtractorExplicit: true, // 隔離變因:本測驗的是身分有沒有一路帶著,不是萃取 + } + if _, exit, _ := RunDirectOnce(cfg, false); exit != 0 { + t.Fatalf("同步應成功,exit=%d", exit) + } + + // 這一輪實際解析出來的身分(machine.json 就鑄在 manifest 旁邊)。 + want := cfg.machineIdentity() + if want.ID == "" { + t.Fatal("這台機器連身分都沒鑄出來——machineid.go 那條線先壞了") + } + + // 本機快照(桌面小幫手讀的那份) + store, err := LoadFolderTreeStore(FolderTreeStorePath(manifest)) + if err != nil { + t.Fatalf("跑完一輪應該有快照可讀:%v", err) + } + tree, ok := store.Trees[root] + if !ok { + t.Fatalf("快照裡沒有這個看守根:%+v", store.Trees) + } + if tree.Machine != want.ID || tree.MachineLabel != want.Label { + t.Errorf("本機快照沒蓋機器身分:machine=%q label=%q(應為 %q/%q)", + tree.Machine, tree.MachineLabel, want.ID, want.Label) + } + + // 上雲酬載 + if treeBody == nil { + t.Fatal("這一輪沒有把樹送上去——沒東西可驗") + } + if treeBody["machine"] != want.ID || treeBody["machine_label"] != want.Label { + t.Errorf("上雲酬載沒帶機器身分:%v/%v(應為 %q/%q)", + treeBody["machine"], treeBody["machine_label"], want.ID, want.Label) + } + // 🔴 兩邊必須是同一份,不是各算各的(本 repo 對「第二份實作」的一貫紅線)。 + if treeBody["machine"] != tree.Machine || treeBody["machine_label"] != tree.MachineLabel { + t.Error("本機快照與上雲酬載的機器身分不一致——有一邊自己另算了一份") + } + // 使用者改過名 ⇒ 顯示名該是他設的那個,比對鍵不該跟著變。 + if want.Label != "教育部 Leo 的 Mac" { + t.Errorf("config 的 machine_label 沒被採用:%q", want.Label) + } + if want.ID == want.Label { + t.Error("改名把比對鍵也改掉了——那會讓雲端以為多出一台機器") + } +}