Files
arcrun-collector/direct_multi_test.go
T
Leo 9df8d037ca fix(t86b): manifest 分實例——換知識庫後同資料夾不再被舊帳跳過
根因:manifestPathFor 雜湊只含資料夾路徑,換實例讀到舊帳本
→ 全部檔案被視為已同步 → 新知識庫靜默拿不到資料(leo 機實證:刪 manifest 才通)。
- instanceHostOf:CypherURL 的 host 作為實例鍵
- manifestPathFor:sha256(host+"\n"+absRoot),單根多根統一公式
- migrateManifestIfNeeded:新名不在、舊名在 → rename 過戶(一次性、冪等)
- 換回舊實例帳本仍在=不重傳(每實例一本帳,設計目標)
已知殘窗(記 tasks.md):升級後第一次掃描前就換實例,舊帳會被過戶給新實例
(舊格式無實例資訊,無從分辨);常態下升級後首掃已把帳過戶給舊實例,窗口極窄。
測試:新增 6 條(分實例/穩定/單根遷移/多根遷移/冪等)+三模組全綠(總管親跑)。
(實作=子 CC;驗證+commit=總管)
2026-07-28 12:59:07 +08:00

371 lines
12 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.
// direct_multi_test.go — daemon-beta task 1 多資料夾 config(單數相容/manifest 分根/多根掃描)。
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func writeDirectConfig(t *testing.T, dir string, cfg map[string]any) string {
t.Helper()
data, _ := json.Marshal(cfg)
p := filepath.Join(dir, "config.json")
if err := os.WriteFile(p, data, 0o600); err != nil {
t.Fatal(err)
}
return p
}
// 單數舊制照舊可載入;Folders() 正規化=一根。
// t86b:單根也分實例,manifest 路徑含 instanceHost 尾碼,不再沿用 cfg.Manifest 原名。
func TestLoadDirectConfigSingularCompat(t *testing.T) {
dir := t.TempDir()
p := writeDirectConfig(t, dir, map[string]any{
"watch_folder": "/tmp/kb", "manifest": filepath.Join(dir, "m.json"),
"cypher_url": "https://x.example", "namespace": "demo",
})
c, err := LoadDirectConfig(p)
if err != nil {
t.Fatal(err)
}
if got := c.Folders(); len(got) != 1 || got[0] != "/tmp/kb" {
t.Fatalf("Folders()=%v", got)
}
// t86b:單根也含實例尾碼,不再沿用裸 cfg.Manifest
mp := c.manifestPathFor("/tmp/kb")
if mp == c.Manifest {
t.Fatalf("t86b:單根 manifest 應含實例尾碼,不應沿用 cfg.Manifest 原路徑,got %s", mp)
}
// 穩定性:同路徑同 config 重算一致
if mp != c.manifestPathFor("/tmp/kb") {
t.Fatal("manifestPathFor 應穩定")
}
}
// 只填 watch_folders 也合法;單數與清單並存時去重保序。
func TestLoadDirectConfigMulti(t *testing.T) {
dir := t.TempDir()
p := writeDirectConfig(t, dir, map[string]any{
"watch_folder": "/tmp/a", "watch_folders": []string{"/tmp/b", "/tmp/a", "/tmp/c"},
"manifest": filepath.Join(dir, "m.json"),
"cypher_url": "https://x.example", "namespace": "demo",
})
c, err := LoadDirectConfig(p)
if err != nil {
t.Fatal(err)
}
got := c.Folders()
want := []string{"/tmp/a", "/tmp/b", "/tmp/c"}
if len(got) != len(want) {
t.Fatalf("Folders()=%v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("Folders()[%d]=%s want %s", i, got[i], want[i])
}
}
// 多根=每根 manifest 各一份、彼此不同、且尾碼對同一路徑穩定
m1 := c.manifestPathFor("/tmp/a")
m2 := c.manifestPathFor("/tmp/b")
if m1 == m2 || m1 == c.Manifest {
t.Fatalf("多根 manifest 應分根:%s vs %s", m1, m2)
}
if m1 != c.manifestPathFor("/tmp/a") {
t.Fatal("同路徑尾碼應穩定")
}
}
// 兩欄都空=缺必填。
func TestLoadDirectConfigMissingFolders(t *testing.T) {
dir := t.TempDir()
p := writeDirectConfig(t, dir, map[string]any{
"manifest": filepath.Join(dir, "m.json"), "cypher_url": "https://x.example", "namespace": "demo",
})
if _, err := LoadDirectConfig(p); err == nil {
t.Fatal("兩欄皆空應報缺必填")
}
}
// 多根 dry-run:各根的檔案都被掃到、事件標 Root、manifest 各自獨立。
func TestRunDirectOnceMultiRootDryRun(t *testing.T) {
base := t.TempDir()
rootA := filepath.Join(base, "rootA")
rootB := filepath.Join(base, "rootB")
for _, d := range []string{rootA, rootB} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(rootA, "a.md"), []byte("# A"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(rootB, "b.md"), []byte("# B"), 0o644); err != nil {
t.Fatal(err)
}
cfg := &DirectConfig{
WatchFolders: []string{rootA, rootB},
Manifest: filepath.Join(base, "manifest.json"),
CypherURL: "https://x.example", Namespace: "demo",
MaxRemoved: DefaultMaxRemovedRatio,
}
results, exit, _ := RunDirectOnce(cfg, true)
if exit != 0 {
t.Fatalf("dry-run 不應失敗:%+v", results)
}
byRoot := map[string][]string{}
for _, r := range results {
if r.Status != "planned" {
t.Fatalf("dry-run 事件應為 planned%+v", r)
}
byRoot[r.Root] = append(byRoot[r.Root], r.Path)
}
if len(byRoot[rootA]) != 1 || byRoot[rootA][0] != "a.md" {
t.Fatalf("rootA 事件錯:%v", byRoot[rootA])
}
if len(byRoot[rootB]) != 1 || byRoot[rootB][0] != "b.md" {
t.Fatalf("rootB 事件錯:%v", byRoot[rootB])
}
}
// t39config 的 `~/` 由 daemon 端展開——安裝器成功頁下載的 config.json 用
// `~/.arcrun-rag/manifest.json` 這種人看的寫法,Go 不展開波浪號=會去找字面上叫 "~" 的資料夾。
func TestLoadDirectConfigExpandsHome(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil || home == "" {
t.Skip("拿不到家目錄,跳過")
}
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
body := `{
"watch_folders": ["~/Documents/知識庫", "/tmp/絕對路徑不動"],
"manifest": "~/.arcrun-rag/manifest.json",
"cypher_url": "https://cypher.example.workers.dev",
"namespace": "ns1"
}`
if err := os.WriteFile(cfgPath, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := LoadDirectConfig(cfgPath)
if err != nil {
t.Fatalf("LoadDirectConfig: %v", err)
}
wantManifest := filepath.Join(home, ".arcrun-rag/manifest.json")
if cfg.Manifest != wantManifest {
t.Errorf("manifest 未展開:got %q want %q", cfg.Manifest, wantManifest)
}
folders := cfg.Folders()
wantFolder := filepath.Join(home, "Documents/知識庫")
if folders[0] != wantFolder {
t.Errorf("watch_folders[0] 未展開:got %q want %q", folders[0], wantFolder)
}
if folders[1] != "/tmp/絕對路徑不動" {
t.Errorf("絕對路徑不該被動:got %q", folders[1])
}
}
// ── t86bmanifest 分實例 ────────────────────────────────────────────────────
// ① 同資料夾、不同 instanceHost → 不同 manifest 路徑。
func TestManifestPathDifferentInstances(t *testing.T) {
dir := t.TempDir()
cfgA := &DirectConfig{
WatchFolder: "/tmp/kb",
Manifest: filepath.Join(dir, "m.json"),
CypherURL: "https://instanceA.example.workers.dev",
Namespace: "demo",
}
cfgB := &DirectConfig{
WatchFolder: "/tmp/kb",
Manifest: filepath.Join(dir, "m.json"),
CypherURL: "https://instanceB.example.workers.dev",
Namespace: "demo",
}
pA := cfgA.manifestPathFor("/tmp/kb")
pB := cfgB.manifestPathFor("/tmp/kb")
if pA == pB {
t.Fatalf("① 不同實例相同資料夾應產生不同 manifest 路徑,got same: %s", pA)
}
}
// ② 同 instance 同夾路徑 → 穩定且含尾碼。
func TestManifestPathStable(t *testing.T) {
dir := t.TempDir()
cfg := &DirectConfig{
WatchFolder: "/tmp/kb",
Manifest: filepath.Join(dir, "m.json"),
CypherURL: "https://instance.workers.dev",
Namespace: "demo",
}
p1 := cfg.manifestPathFor("/tmp/kb")
p2 := cfg.manifestPathFor("/tmp/kb")
if p1 != p2 {
t.Fatalf("② 相同 instance+路徑應穩定,got %s vs %s", p1, p2)
}
if p1 == cfg.Manifest {
t.Fatal("② manifestPathFor 應含實例尾碼,不應沿用 cfg.Manifest 原路徑")
}
}
// ③ 遷移:舊單根名(cfg.Manifest)存在、新名不存在 → rename 後讀得到既有 entries。
func TestManifestMigrateSingleRoot(t *testing.T) {
dir := t.TempDir()
cfg := &DirectConfig{
WatchFolder: dir,
Manifest: filepath.Join(dir, "manifest.json"),
CypherURL: "https://instance.workers.dev",
Namespace: "demo",
MaxRemoved: DefaultMaxRemovedRatio,
}
absRoot, _ := filepath.Abs(dir)
// 在舊路徑(cfg.Manifest)寫一份有資料的 manifest
old := &Manifest{
FolderID: "old-folder-id",
Root: absRoot,
Entries: map[string]*ManifestEntry{"doc.md": {ContentHash: "sha256:aabb", IngestedHash: "sha256:aabb", Size: 42}},
}
if err := old.Save(cfg.Manifest); err != nil {
t.Fatal(err)
}
newPath := cfg.manifestPathFor(absRoot)
if newPath == cfg.Manifest {
t.Fatal("前提失效:新路徑應與舊路徑不同")
}
if _, err := os.Stat(newPath); err == nil {
t.Fatal("新路徑不應已存在")
}
cfg.migrateManifestIfNeeded(absRoot, newPath)
if _, err := os.Stat(cfg.Manifest); err == nil {
t.Fatal("③ 遷移後舊路徑應已消失")
}
m, err := LoadManifest(newPath, absRoot)
if err != nil {
t.Fatalf("③ 遷移後讀取新路徑失敗:%v", err)
}
if _, ok := m.Entries["doc.md"]; !ok {
t.Fatal("③ 遷移後 entries 應完整保留")
}
}
// ③ 遷移:舊多根名(sha256(absRoot only))存在 → rename 到新路徑(sha256(host+absRoot))。
func TestManifestMigrateMultiRootOldFormula(t *testing.T) {
dir := t.TempDir()
cfg := &DirectConfig{
WatchFolders: []string{dir},
Manifest: filepath.Join(dir, "manifest.json"),
CypherURL: "https://instance.workers.dev",
Namespace: "demo",
MaxRemoved: DefaultMaxRemovedRatio,
}
absRoot, _ := filepath.Abs(dir)
// 計算舊多根路徑(只含路徑雜湊,無 instanceHost
sum := sha256.Sum256([]byte(absRoot))
ext := filepath.Ext(cfg.Manifest)
oldMultiPath := strings.TrimSuffix(cfg.Manifest, ext) + "-" + hex.EncodeToString(sum[:4]) + ext
old := &Manifest{
FolderID: "multi-old-id",
Root: absRoot,
Entries: map[string]*ManifestEntry{"wiki.md": {ContentHash: "sha256:cc00"}},
}
if err := old.Save(oldMultiPath); err != nil {
t.Fatal(err)
}
newPath := cfg.manifestPathFor(absRoot)
if newPath == oldMultiPath {
t.Fatal("前提失效:新路徑應與舊多根路徑不同(instanceHost 加進去了)")
}
cfg.migrateManifestIfNeeded(absRoot, newPath)
if _, err := os.Stat(oldMultiPath); err == nil {
t.Fatal("③ 多根遷移後舊路徑應已消失")
}
m, err := LoadManifest(newPath, absRoot)
if err != nil {
t.Fatalf("③ 多根遷移後讀取失敗:%v", err)
}
if _, ok := m.Entries["wiki.md"]; !ok {
t.Fatal("③ 多根遷移後 entries 應保留")
}
}
// ④ 冪等:新名已存在 → 再跑 migrateManifestIfNeeded 不覆蓋新名內容。
func TestManifestMigrateIdempotent(t *testing.T) {
dir := t.TempDir()
cfg := &DirectConfig{
WatchFolder: dir,
Manifest: filepath.Join(dir, "manifest.json"),
CypherURL: "https://instance.workers.dev",
Namespace: "demo",
MaxRemoved: DefaultMaxRemovedRatio,
}
absRoot, _ := filepath.Abs(dir)
newPath := cfg.manifestPathFor(absRoot)
// 直接在新路徑寫一份 manifest
fresh := &Manifest{
FolderID: "fresh-id",
Root: absRoot,
Entries: map[string]*ManifestEntry{"keep.md": {ContentHash: "sha256:dd11"}},
}
if err := fresh.Save(newPath); err != nil {
t.Fatal(err)
}
// 也在舊路徑留一份(確認冪等時不被搬來蓋掉新名)
stale := &Manifest{
FolderID: "stale-id",
Root: absRoot,
Entries: map[string]*ManifestEntry{"old.md": {ContentHash: "sha256:ee22"}},
}
if err := stale.Save(cfg.Manifest); err != nil {
t.Fatal(err)
}
// 跑兩次
cfg.migrateManifestIfNeeded(absRoot, newPath)
cfg.migrateManifestIfNeeded(absRoot, newPath)
m, err := LoadManifest(newPath, absRoot)
if err != nil {
t.Fatalf("④ 冪等後讀取失敗:%v", err)
}
if _, ok := m.Entries["keep.md"]; !ok {
t.Fatal("④ 冪等後新路徑內容不應被蓋掉")
}
if _, ok := m.Entries["old.md"]; ok {
t.Fatal("④ 冪等後新路徑不應出現舊路徑的 entries")
}
}
func TestExpandHomeEdgeCases(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil || home == "" {
t.Skip("拿不到家目錄,跳過")
}
cases := map[string]string{
"~": home,
"~/a/b": filepath.Join(home, "a/b"),
"/abs/path": "/abs/path",
"relative/dir": "relative/dir",
"": "",
"~notme/x": "~notme/x", // 只認 `~/``~user` 形式不猜(Go 無法解析他人家目錄)
}
for in, want := range cases {
if got := expandHome(in); got != want {
t.Errorf("expandHome(%q)%qwant %q", in, got, want)
}
}
}