9df8d037ca
根因:manifestPathFor 雜湊只含資料夾路徑,換實例讀到舊帳本 → 全部檔案被視為已同步 → 新知識庫靜默拿不到資料(leo 機實證:刪 manifest 才通)。 - instanceHostOf:CypherURL 的 host 作為實例鍵 - manifestPathFor:sha256(host+"\n"+absRoot),單根多根統一公式 - migrateManifestIfNeeded:新名不在、舊名在 → rename 過戶(一次性、冪等) - 換回舊實例帳本仍在=不重傳(每實例一本帳,設計目標) 已知殘窗(記 tasks.md):升級後第一次掃描前就換實例,舊帳會被過戶給新實例 (舊格式無實例資訊,無從分辨);常態下升級後首掃已把帳過戶給舊實例,窗口極窄。 測試:新增 6 條(分實例/穩定/單根遷移/多根遷移/冪等)+三模組全綠(總管親跑)。 (實作=子 CC;驗證+commit=總管)
371 lines
12 KiB
Go
371 lines
12 KiB
Go
// 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])
|
||
}
|
||
}
|
||
|
||
// t39:config 的 `~/` 由 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])
|
||
}
|
||
}
|
||
|
||
// ── t86b:manifest 分實例 ────────────────────────────────────────────────────
|
||
|
||
// ① 同資料夾、不同 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)=%q,want %q", in, got, want)
|
||
}
|
||
}
|
||
}
|