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:
@@ -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
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user