fix(t86b): manifest 分實例——換知識庫後同資料夾不再被舊帳跳過

根因:manifestPathFor 雜湊只含資料夾路徑,換實例讀到舊帳本
→ 全部檔案被視為已同步 → 新知識庫靜默拿不到資料(leo 機實證:刪 manifest 才通)。
- instanceHostOf:CypherURL 的 host 作為實例鍵
- manifestPathFor:sha256(host+"\n"+absRoot),單根多根統一公式
- migrateManifestIfNeeded:新名不在、舊名在 → rename 過戶(一次性、冪等)
- 換回舊實例帳本仍在=不重傳(每實例一本帳,設計目標)
已知殘窗(記 tasks.md):升級後第一次掃描前就換實例,舊帳會被過戶給新實例
(舊格式無實例資訊,無從分辨);常態下升級後首掃已把帳過戶給舊實例,窗口極窄。
測試:新增 6 條(分實例/穩定/單根遷移/多根遷移/冪等)+三模組全綠(總管親跑)。
(實作=子 CC;驗證+commit=總管)
This commit is contained in:
2026-07-28 12:59:07 +08:00
parent 02833588e5
commit 9df8d037ca
2 changed files with 241 additions and 10 deletions
+48 -7
View File
@@ -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
+193 -3
View File
@@ -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) {
}
}
// ── 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 == "" {