From 9df8d037cad6d85afbd45548a4353216d66a25b4 Mon Sep 17 00:00:00 2001 From: richblack Date: Tue, 28 Jul 2026 12:59:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(t86b):=20manifest=20=E5=88=86=E5=AF=A6?= =?UTF-8?q?=E4=BE=8B=E2=80=94=E2=80=94=E6=8F=9B=E7=9F=A5=E8=AD=98=E5=BA=AB?= =?UTF-8?q?=E5=BE=8C=E5=90=8C=E8=B3=87=E6=96=99=E5=A4=BE=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=A2=AB=E8=88=8A=E5=B8=B3=E8=B7=B3=E9=81=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:manifestPathFor 雜湊只含資料夾路徑,換實例讀到舊帳本 → 全部檔案被視為已同步 → 新知識庫靜默拿不到資料(leo 機實證:刪 manifest 才通)。 - instanceHostOf:CypherURL 的 host 作為實例鍵 - manifestPathFor:sha256(host+"\n"+absRoot),單根多根統一公式 - migrateManifestIfNeeded:新名不在、舊名在 → rename 過戶(一次性、冪等) - 換回舊實例帳本仍在=不重傳(每實例一本帳,設計目標) 已知殘窗(記 tasks.md):升級後第一次掃描前就換實例,舊帳會被過戶給新實例 (舊格式無實例資訊,無從分辨);常態下升級後首掃已把帳過戶給舊實例,窗口極窄。 測試:新增 6 條(分實例/穩定/單根遷移/多根遷移/冪等)+三模組全綠(總管親跑)。 (實作=子 CC;驗證+commit=總管) --- direct.go | 55 ++++++++++-- direct_multi_test.go | 196 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 241 insertions(+), 10 deletions(-) diff --git a/direct.go b/direct.go index 6ff3ffa..e8f2519 100644 --- a/direct.go +++ b/direct.go @@ -27,6 +27,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -205,18 +206,57 @@ func (c *DirectConfig) Folders() []string { return out } -// manifestPathFor 回傳某根的 manifest 路徑。單根=沿用 cfg.Manifest(升級不丟既有狀態); -// 多根=每根一份,基底名加 root 絕對路徑的 sha256 前 8 碼尾碼(路徑穩定=尾碼穩定)。 -func (c *DirectConfig) manifestPathFor(absRoot string) string { - folders := c.Folders() - if len(folders) <= 1 { - return c.Manifest +// instanceHostOf extracts the host from a CypherURL to use as a per-instance +// distinguisher in manifest paths (t86b). Falls back to the full URL if parsing fails. +func instanceHostOf(cypherURL string) string { + u, err := url.Parse(cypherURL) + if err != nil || u.Host == "" { + return cypherURL } - sum := sha256.Sum256([]byte(absRoot)) + return u.Host +} + +// manifestPathFor 回傳某根的 manifest 路徑。 +// t86b:雜湊改為 sha256(instanceHost + "\n" + absRoot),讓每個(實例, 資料夾)組合對應 +// 獨立帳本——換知識庫實例後同資料夾不再重用舊帳本,避免「全部視為已同步」靜默跳過。 +// 舊格式遷移見 migrateManifestIfNeeded:啟動/掃描時若新名不存在但舊名存在則 rename 過來。 +func (c *DirectConfig) manifestPathFor(absRoot string) string { + host := instanceHostOf(c.CypherURL) + sum := sha256.Sum256([]byte(host + "\n" + absRoot)) ext := filepath.Ext(c.Manifest) return strings.TrimSuffix(c.Manifest, ext) + "-" + hex.EncodeToString(sum[:4]) + ext } +// oldManifestPaths 回傳 t86b 之前版本對同一個 absRoot 會產生的 manifest 路徑, +// 供遷移時確認是否有舊帳本需要搬移。 +// 優先序:①舊多根(只含路徑雜湊)→ ②舊單根(直用 cfg.Manifest)。 +func (c *DirectConfig) oldManifestPaths(absRoot string) []string { + ext := filepath.Ext(c.Manifest) + base := strings.TrimSuffix(c.Manifest, ext) + // 舊多根公式:sha256(absRoot only) + sumPathOnly := sha256.Sum256([]byte(absRoot)) + pathOnlyPath := base + "-" + hex.EncodeToString(sumPathOnly[:4]) + ext + // 舊單根公式:直用 cfg.Manifest + return []string{pathOnlyPath, c.Manifest} +} + +// migrateManifestIfNeeded 在 newPath 不存在時,把最先找到的舊格式 manifest rename 過來。 +// 一次性、冪等:newPath 已存在時直接 return;rename 失敗靜默忽略(最多這一輪重傳,不影響正確性)。 +func (c *DirectConfig) migrateManifestIfNeeded(absRoot, newPath string) { + if _, err := os.Stat(newPath); err == nil { + return // 新路徑已存在,無需遷移 + } + for _, oldPath := range c.oldManifestPaths(absRoot) { + if oldPath == newPath { + continue + } + if _, err := os.Stat(oldPath); err == nil { + _ = os.Rename(oldPath, newPath) + return + } + } +} + // directHTTP 是 direct 模式共用的 HTTP client(萃取 workflow 可能同步跑 LLM,放寬 timeout)。 var directHTTP = &http.Client{Timeout: 300 * time.Second} @@ -394,6 +434,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes if err != nil { return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil } + cfg.migrateManifestIfNeeded(absRoot, absManifest) // t86b:一次性遷移舊格式帳本 m, err := LoadManifest(absManifest, absRoot) if err != nil { return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil diff --git a/direct_multi_test.go b/direct_multi_test.go index ca9d1db..5a957fb 100644 --- a/direct_multi_test.go +++ b/direct_multi_test.go @@ -2,9 +2,12 @@ package main import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "os" "path/filepath" + "strings" "testing" ) @@ -19,6 +22,7 @@ func writeDirectConfig(t *testing.T, dir string, cfg map[string]any) string { } // 單數舊制照舊可載入;Folders() 正規化=一根。 +// t86b:單根也分實例,manifest 路徑含 instanceHost 尾碼,不再沿用 cfg.Manifest 原名。 func TestLoadDirectConfigSingularCompat(t *testing.T) { dir := t.TempDir() p := writeDirectConfig(t, dir, map[string]any{ @@ -32,9 +36,14 @@ func TestLoadDirectConfigSingularCompat(t *testing.T) { if got := c.Folders(); len(got) != 1 || got[0] != "/tmp/kb" { t.Fatalf("Folders()=%v", got) } - // 單根 manifest 沿用原路徑(升級不丟狀態) - if mp := c.manifestPathFor("/tmp/kb"); mp != c.Manifest { - t.Fatalf("單根 manifest 應沿用原路徑,got %s", mp) + // 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 應穩定") } } @@ -159,6 +168,187 @@ func TestLoadDirectConfigExpandsHome(t *testing.T) { } } +// ── 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 == "" {