feat(ingest-hash-trigger): 凍結 collector/ingest schema+Go collector 骨架(issue #5, SDD task 1+2)

- schemas/collector-trigger.v1.schema.json:collector→named-webhook 觸發 payload(source_hash/r2_key/renamed/R6 warnings)
- schemas/kbdb-ingest-request.v1.schema.json:source_hash 必填缺=400;儲存映射走 metadata_json.$.source_hash(守 kbdb 表不變鐵律);同 hash 重送=200 already_ingested
- schemas/MIGRATION-webhook-payload.md:舊 Gitea push payload 逐欄對照(給 task 4 改寫 workflow)
- collector/:Go module(純 stdlib)——manifest 讀寫+mtime fast-path 掃描+renamed hash 配對+40% 大量刪除防呆;CLI collector scan
- go test ./... 全綠 7/7(added/modified/removed/renamed/防呆五情境+fast-path+manifest 往返)
- Node 版 collector 標 legacy 並存(SDD task 4 拆除)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 20:31:11 +08:00
parent eff30bd741
commit bb96bceb04
6 changed files with 790 additions and 1 deletions
+34 -1
View File
@@ -1,4 +1,37 @@
# collector — 收集端骨架(rag-wave1 T3+T4
# collector
> ⚠️ **本目錄有兩代 collector 並存(2026-07-19**
>
> - **新(現役開發線)= Go 版 hash 偵測 collector**`*.go`SDD `ingest-hash-trigger` task 2)——
> 變更偵測回歸檔案系統,manifestcontent hash**不經 git、不經 Gitea**。見下方「Go 版」段。
> - **舊(legacy,待 SDD task 4 拆除)= Node 版 Gitea push 鏈**`index.js`/`transform.js`/`git-sync.js`/`config.js`
> rag-wave1 T3/T4 產物)——watch → git commit/push → Gitea webhook。這正是 de-Gitea 要取代的鏈,
> 保留只為現行本機 demo 鏈還在用;新功能一律做在 Go 版。
## Go 版:hash 偵測 collectorSDD ingest-hash-trigger
```
collector scan --root <知識資料夾> --manifest <manifest.json> [--max-removed-ratio 0.4] [--dry-run]
```
一次掃描:走訪資料夾(先只認 .md/.markdown/.txt/.docx/.pptx/.pdf)→ mtime+size fast-path
(沒變→沿用 manifest hash;變了才算 sha256)→ 對照 manifest 產出事件 → 事件 JSON
(符合 `schemas/collector-trigger.v1.schema.json`)輸出 stdout → 更新 manifest`--dry-run` 不寫)。
- **事件分類順序(design §3**:先把本輪 removed×added 以 content_hash 配對成 `renamed`
(只更新路徑映射,不 retire、不重萃);再分 added / modified / removed。
- **大量刪除防呆(R6**removed 數 > manifest 條目 × 40%`--max-removed-ratio` 可調)→
removed 全部不執行、manifest 條目保留、輸出 `mass_delete_guard` 警告。
- **重試語意**`ingested_hash` 只會在(未來的)上傳/ingest 成功後回寫;本階段永不寫它,
所以「偵測過但未成功 ingest」的檔每輪都會重發 added/modified——這是設計(design §2),不是 bug。
- **本階段不接網路**daemon 常駐(launchd)、R2 上傳、打 arcrun named-webhook=之後的 task
SDD task 3/4、journeys/user-onboarding 環 6)。
測試:`go test ./...`added / modified / removed / renamed / 大量刪除防呆五情境+fast-pathmanifest 往返)。
---
# legacyNode 版收集端骨架(rag-wave1 T3+T4
> design.md §4;跑在**客戶端機器**(NAS/VM,或導入者代管的 VPS),不是 arcrun workflowarcrun 零件禁檔案系統,見 CLAUDE.md 紅線)。
> ⚠️ **本骨架端到端(真實 NAS/VM + 真實 Gitea remote + systemd 常駐)需在本機/客戶環境驗,雲端 sandbox 沒有這些條件**——這裡只做得到:程式邏輯本身可跑、對本機臨時目錄的煙測(見下方「已驗證」)。
+3
View File
@@ -0,0 +1,3 @@
module arcrun-rag/collector
go 1.22
+74
View File
@@ -0,0 +1,74 @@
// collector — hash 差異偵測 collectorSDD ingest-hash-triggerGo 版骨架)。
//
// 用法:
//
// collector scan --root <知識資料夾> --manifest <manifest.json 路徑> \
// [--max-removed-ratio 0.4] [--dry-run]
//
// 一次掃描:走訪 root、對照 manifest、把事件(符合
// schemas/collector-trigger.v1.schema.json)以 JSON 輸出到 stdout,並更新 manifest
// --dry-run 不寫)。daemon 常駐/launchd/R2 上傳/打 named-webhook=之後的 task
// 本階段不接網路。
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
)
func main() {
if len(os.Args) < 2 || os.Args[1] != "scan" {
fmt.Fprintln(os.Stderr, "用法: collector scan --root <dir> --manifest <file> [--max-removed-ratio 0.4] [--dry-run]")
os.Exit(2)
}
fs := flag.NewFlagSet("scan", flag.ExitOnError)
root := fs.String("root", "", "知識資料夾根路徑(必填)")
manifestPath := fs.String("manifest", "", "manifest JSON 檔路徑(必填;不存在會建新)")
ratio := fs.Float64("max-removed-ratio", DefaultMaxRemovedRatio, "大量刪除防呆門檻(removed 數 > manifest 條目 × 本值 → 全部不下架、只發警告)")
dryRun := fs.Bool("dry-run", false, "只輸出事件,不更新 manifest")
if err := fs.Parse(os.Args[2:]); err != nil {
os.Exit(2)
}
if *root == "" || *manifestPath == "" {
fmt.Fprintln(os.Stderr, "錯誤:--root 與 --manifest 皆為必填")
os.Exit(2)
}
absRoot, err := filepath.Abs(*root)
if err != nil {
fatal(err)
}
absManifest, err := filepath.Abs(*manifestPath)
if err != nil {
fatal(err)
}
m, err := LoadManifest(absManifest, absRoot)
if err != nil {
fatal(err)
}
payload, err := Scan(absRoot, m, ScanOptions{
MaxRemovedRatio: *ratio,
SkipPaths: map[string]bool{absManifest: true}, // manifest 若住在 root 底下,不掃自己
})
if err != nil {
fatal(err)
}
if !*dryRun {
if err := m.Save(absManifest); err != nil {
fatal(err)
}
}
out, err := json.MarshalIndent(payload, "", " ")
if err != nil {
fatal(err)
}
fmt.Println(string(out))
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, "collector:", err)
os.Exit(1)
}
+103
View File
@@ -0,0 +1,103 @@
// manifest.go — collector 本機 manifest 讀寫(SDD ingest-hash-trigger design §2)。
// manifest 只由 collector 寫入,雲端不得回寫(design §6-4)。
package main
import (
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
// ManifestEntry 是單一檔案在 manifest 裡的狀態。
// - mtime 只決定「要不要重算 hash」(fast-path),絕不作為內容變更判準。
// - ingested_hash 與 content_hash 分開存=可表達「已變更但尚未成功 ingest」;
// 上傳失敗不更新 ingested_hash,天然可重試。本階段(不接網路)永不寫 ingested_hash
// 留給之後的上傳/webhook task 在成功後回寫。
type ManifestEntry struct {
ContentHash string `json:"content_hash"`
Size int64 `json:"size"`
Mtime int64 `json:"mtime"`
IngestedHash string `json:"ingested_hash,omitempty"`
IngestedAt int64 `json:"ingested_at,omitempty"`
}
// Manifest 對應一個被勾選的資料夾。
type Manifest struct {
FolderID string `json:"folder_id"`
Root string `json:"root"`
Entries map[string]*ManifestEntry `json:"entries"`
}
// newUUID 產生 RFC 4122 v4 UUID(純 stdlib)。
func newUUID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
// LoadManifest 讀 manifest 檔;不存在=新資料夾,生成 folder_id。
func LoadManifest(path, root string) (*Manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
id, uerr := newUUID()
if uerr != nil {
return nil, uerr
}
return &Manifest{FolderID: id, Root: root, Entries: map[string]*ManifestEntry{}}, nil
}
return nil, err
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("manifest %s 解析失敗(不覆寫、直接報錯): %w", path, err)
}
if m.Entries == nil {
m.Entries = map[string]*ManifestEntry{}
}
if m.FolderID == "" {
id, uerr := newUUID()
if uerr != nil {
return nil, uerr
}
m.FolderID = id
}
if root != "" {
m.Root = root
}
return &m, nil
}
// Save 原子寫入(temp + rename),避免掃描中斷留半個 JSON。
func (m *Manifest) Save(path string) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".manifest-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := tmp.Write(append(data, '\n')); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return err
}
return os.Rename(tmpName, path)
}
+289
View File
@@ -0,0 +1,289 @@
// scan.go — 掃描迴圈與差異分類(SDD ingest-hash-trigger design §3)。
// 事件順序:先把本輪 removed×added 以 content_hash 配對成 renamed(只更新路徑映射),
// 再分類其餘 added/modified/removedremoved 數 > manifest 條目 × 門檻(預設 40%)
// → removed 全部不執行、改發警告(R6)。本階段不接網路,事件輸出到 stdout。
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// 先只認 .md 與常見文件檔(transform/其他格式是之後的 task)。
var allowedExt = map[string]bool{
".md": true,
".markdown": true,
".txt": true,
".docx": true,
".pptx": true,
".pdf": true,
}
// ---- 輸出 payload(對應 schemas/collector-trigger.v1.schema.json----
type Event struct {
Type string `json:"type"`
Path string `json:"path"`
OldPath string `json:"old_path,omitempty"`
SourceHash string `json:"source_hash"`
Size *int64 `json:"size,omitempty"`
R2Key string `json:"r2_key,omitempty"`
}
type Warning struct {
Code string `json:"code"`
Message string `json:"message"`
RemovedCount int `json:"removed_count,omitempty"`
ManifestCount int `json:"manifest_count,omitempty"`
ThresholdRatio float64 `json:"threshold_ratio,omitempty"`
}
type TriggerPayload struct {
SchemaVersion int `json:"schema_version"`
FolderID string `json:"folder_id"`
Root string `json:"root,omitempty"`
GeneratedAt int64 `json:"generated_at,omitempty"`
Events []Event `json:"events"`
Warnings []Warning `json:"warnings,omitempty"`
}
type ScanOptions struct {
// MaxRemovedRatio:單輪 removed 數 > manifest 條目數 × 本值 → 觸發大量刪除防呆(R6)。
MaxRemovedRatio float64
// SkipPaths:絕對路徑黑名單(如 manifest 檔自己住在 root 底下時)。
SkipPaths map[string]bool
}
const DefaultMaxRemovedRatio = 0.4
type fileState struct {
hash string // sha256:<hex>
size int64
mtime int64
}
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
}
func r2KeyOf(sourceHash string) string {
return "raw/" + strings.TrimPrefix(sourceHash, "sha256:")
}
// Scan 走訪 root、對照並更新 manifest、產出一輪事件。
// manifest 更新原則:content_hash/size/mtime 反映現況;ingested_hash/ingested_at
// 只搬運(renamed)與保留(modified),本函式永不設值——那是上傳成功後的事。
func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
if opts.MaxRemovedRatio <= 0 {
opts.MaxRemovedRatio = DefaultMaxRemovedRatio
}
orig := m.Entries
manifestCountBefore := len(orig)
// 1) 走訪檔案系統,建立現況(mtime+size fast-path:沒變→沿用 manifest hash,變了才算 sha256)。
current := map[string]fileState{}
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
if werr != nil {
return werr
}
name := d.Name()
if d.IsDir() {
if p != root && strings.HasPrefix(name, ".") {
return filepath.SkipDir // 隱藏目錄(.git、.obsidian…)整棵跳過
}
return nil
}
if strings.HasPrefix(name, ".") {
return nil
}
if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] {
return nil
}
if !allowedExt[strings.ToLower(filepath.Ext(name))] {
return nil
}
info, ierr := d.Info()
if ierr != nil {
return ierr
}
rel, rerr := filepath.Rel(root, p)
if rerr != nil {
return rerr
}
rel = filepath.ToSlash(rel)
st := fileState{size: info.Size(), mtime: info.ModTime().Unix()}
if e, ok := orig[rel]; ok && e.ContentHash != "" && e.Mtime == st.mtime && e.Size == st.size {
st.hash = e.ContentHash // fast-pathmtime+size 沒變,跳過重算
} else {
h, herr := hashFile(p)
if herr != nil {
return herr
}
st.hash = h
}
current[rel] = st
return nil
})
if err != nil {
return nil, err
}
// 2) 初分:added 候選(現況有、manifest 無)與 removed 候選(manifest 有、現況無)。
var addedPaths, removedPaths []string
for p := range current {
if _, ok := orig[p]; !ok {
addedPaths = append(addedPaths, p)
}
}
for p := range orig {
if _, ok := current[p]; !ok {
removedPaths = append(removedPaths, p)
}
}
sort.Strings(addedPaths)
sort.Strings(removedPaths)
// 3) 先配對 renameddesign §3 順序 1):removed×added 以 content_hash 配對,
// 配上=只更新路徑映射,不 retire、不重萃、不重傳。同 hash 多候選→排序後貪婪配對(確定性)。
removedByHash := map[string][]string{}
for _, p := range removedPaths {
h := orig[p].ContentHash
removedByHash[h] = append(removedByHash[h], p)
}
renamedOldOf := map[string]string{} // newPath -> oldPath
pairedOld := map[string]bool{}
var events []Event
for _, np := range addedPaths {
h := current[np].hash
cands := removedByHash[h]
if len(cands) == 0 {
continue
}
op := cands[0]
removedByHash[h] = cands[1:]
pairedOld[op] = true
renamedOldOf[np] = op
events = append(events, Event{Type: "renamed", Path: np, OldPath: op, SourceHash: h})
}
// 4) added:真新檔+「曾偵測但從未成功 ingest」的檔(重試語意,design §2)。
sortedCurrent := make([]string, 0, len(current))
for p := range current {
sortedCurrent = append(sortedCurrent, p)
}
sort.Strings(sortedCurrent)
addedEvent := func(p string) Event {
st := current[p]
size := st.size
return Event{Type: "added", Path: p, SourceHash: st.hash, Size: &size, R2Key: r2KeyOf(st.hash)}
}
for _, p := range sortedCurrent {
if op, isRenamed := renamedOldOf[p]; isRenamed {
if orig[op].IngestedHash == "" { // 改名的檔其實從未 ingest 成功 → 補一發 added
events = append(events, addedEvent(p))
}
continue
}
if _, existed := orig[p]; !existed {
events = append(events, addedEvent(p)) // 真新檔
} else if orig[p].IngestedHash == "" {
events = append(events, addedEvent(p)) // 上輪偵測過但 ingest 未成功 → 重試
}
}
// 5) modifiedmanifest 有、現況有、content_hash != ingested_hashdesign §3 順序 3)。
for _, p := range sortedCurrent {
e, existed := orig[p]
if !existed {
continue
}
if _, isRenamed := renamedOldOf[p]; isRenamed {
continue
}
if e.IngestedHash != "" && current[p].hash != e.IngestedHash {
st := current[p]
size := st.size
events = append(events, Event{Type: "modified", Path: p, SourceHash: st.hash, Size: &size, R2Key: r2KeyOf(st.hash)})
}
}
// 6) removed(扣掉已配對走的)+大量刪除防呆(R6)。
var finalRemoved []string
for _, p := range removedPaths {
if !pairedOld[p] {
finalRemoved = append(finalRemoved, p)
}
}
var warnings []Warning
guardTripped := manifestCountBefore > 0 &&
float64(len(finalRemoved)) > opts.MaxRemovedRatio*float64(manifestCountBefore)
if guardTripped {
warnings = append(warnings, Warning{
Code: "mass_delete_guard",
Message: fmt.Sprintf(
"本輪偵測到 %d/%d 個檔案消失(超過 %.0f%% 門檻)——可能是資料夾未掛載或同步半途。本輪全部「不」下架,請確認資料夾完好後再放行。",
len(finalRemoved), manifestCountBefore, opts.MaxRemovedRatio*100),
RemovedCount: len(finalRemoved),
ManifestCount: manifestCountBefore,
ThresholdRatio: opts.MaxRemovedRatio,
})
} else {
for _, p := range finalRemoved {
events = append(events, Event{Type: "removed", Path: p, SourceHash: orig[p].ContentHash})
}
}
// 7) 更新 manifestrebuild):現況檔全數收錄;ingested_* 由舊 entry(或 renamed 的舊路徑)搬運。
// 防呆觸發時 removed 條目保留(下輪重評、警告會再響,直到人確認或檔案回來)。
newEntries := make(map[string]*ManifestEntry, len(current))
for p, st := range current {
ne := &ManifestEntry{ContentHash: st.hash, Size: st.size, Mtime: st.mtime}
var carry *ManifestEntry
if op, isRenamed := renamedOldOf[p]; isRenamed {
carry = orig[op]
} else if e, ok := orig[p]; ok {
carry = e
}
if carry != nil {
ne.IngestedHash = carry.IngestedHash
ne.IngestedAt = carry.IngestedAt
}
newEntries[p] = ne
}
if guardTripped {
for _, p := range finalRemoved {
newEntries[p] = orig[p]
}
}
m.Entries = newEntries
m.Root = root
if events == nil {
events = []Event{}
}
return &TriggerPayload{
SchemaVersion: 1,
FolderID: m.FolderID,
Root: root,
GeneratedAt: time.Now().Unix(),
Events: events,
Warnings: warnings,
}, nil
}
+287
View File
@@ -0,0 +1,287 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
"time"
)
var baseTime = time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
func writeFile(t *testing.T, root, rel, content string, mtime time.Time) {
t.Helper()
p := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(p, mtime, mtime); err != nil {
t.Fatal(err)
}
}
func hashOf(content string) string {
sum := sha256.Sum256([]byte(content))
return "sha256:" + hex.EncodeToString(sum[:])
}
func newTestManifest() *Manifest {
return &Manifest{FolderID: "test-folder-id", Entries: map[string]*ManifestEntry{}}
}
// markIngested 模擬「上一輪 ingest 全部成功」:ingested_hash 補成 content_hash。
func markIngested(m *Manifest) {
for _, e := range m.Entries {
e.IngestedHash = e.ContentHash
e.IngestedAt = 1
}
}
func mustScan(t *testing.T, root string, m *Manifest) *TriggerPayload {
t.Helper()
p, err := Scan(root, m, ScanOptions{})
if err != nil {
t.Fatal(err)
}
return p
}
func eventsOfType(p *TriggerPayload, typ string) []Event {
var out []Event
for _, e := range p.Events {
if e.Type == typ {
out = append(out, e)
}
}
return out
}
// 情境 1:added——新檔進來,事件帶 source_hash / size / r2_keymanifest 記錄但不標已 ingest。
func TestAdded(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "a.md", "hello world\n", baseTime)
m := newTestManifest()
p := mustScan(t, root, m)
if p.SchemaVersion != 1 || p.FolderID != "test-folder-id" {
t.Fatalf("payload 頭部不對: %+v", p)
}
if len(p.Events) != 1 {
t.Fatalf("要 1 個事件,得到 %d: %+v", len(p.Events), p.Events)
}
ev := p.Events[0]
wantHash := hashOf("hello world\n")
if ev.Type != "added" || ev.Path != "a.md" || ev.SourceHash != wantHash {
t.Fatalf("added 事件不對: %+v", ev)
}
if ev.R2Key != "raw/"+wantHash[len("sha256:"):] {
t.Fatalf("r2_key 不對: %s", ev.R2Key)
}
if ev.Size == nil || *ev.Size != int64(len("hello world\n")) {
t.Fatalf("size 不對: %+v", ev.Size)
}
e := m.Entries["a.md"]
if e == nil || e.ContentHash != wantHash || e.IngestedHash != "" {
t.Fatalf("manifest 記錄不對: %+v", e)
}
// R3:模擬 ingest 成功後重掃(=daemon 重啟),未變更檔案零事件。
markIngested(m)
p2 := mustScan(t, root, m)
if len(p2.Events) != 0 {
t.Fatalf("未變更檔案不得重送,得到: %+v", p2.Events)
}
}
// 情境 2modified——內容變(mtime 也變)→ modified 事件帶新 hashingested_hash 保留舊值(可重試語意)。
func TestModified(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "a.md", "v1\n", baseTime)
m := newTestManifest()
mustScan(t, root, m)
markIngested(m)
oldIngested := m.Entries["a.md"].IngestedHash
writeFile(t, root, "a.md", "v2 changed\n", baseTime.Add(time.Hour))
p := mustScan(t, root, m)
if len(p.Events) != 1 {
t.Fatalf("要 1 個事件,得到: %+v", p.Events)
}
ev := p.Events[0]
wantHash := hashOf("v2 changed\n")
if ev.Type != "modified" || ev.Path != "a.md" || ev.SourceHash != wantHash || ev.R2Key == "" {
t.Fatalf("modified 事件不對: %+v", ev)
}
e := m.Entries["a.md"]
if e.ContentHash != wantHash {
t.Fatalf("manifest content_hash 未更新: %+v", e)
}
if e.IngestedHash != oldIngested {
t.Fatalf("ingested_hash 不該在掃描階段被動(上傳成功才回寫): %+v", e)
}
}
// 情境 3:removed——比例低於防呆門檻時正常發下架事件(帶最後已知 hash),manifest 條目移除。
func TestRemoved(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "a.md", "aaa\n", baseTime)
writeFile(t, root, "b.md", "bbb\n", baseTime)
writeFile(t, root, "c.md", "ccc\n", baseTime)
m := newTestManifest()
mustScan(t, root, m)
markIngested(m)
if err := os.Remove(filepath.Join(root, "b.md")); err != nil {
t.Fatal(err)
}
p := mustScan(t, root, m) // 1/3 = 33% ≤ 40% → 放行
if len(p.Warnings) != 0 {
t.Fatalf("不該觸發防呆: %+v", p.Warnings)
}
rm := eventsOfType(p, "removed")
if len(rm) != 1 || rm[0].Path != "b.md" || rm[0].SourceHash != hashOf("bbb\n") {
t.Fatalf("removed 事件不對: %+v", rm)
}
if len(p.Events) != 1 {
t.Fatalf("其餘檔案不該有事件: %+v", p.Events)
}
if _, ok := m.Entries["b.md"]; ok {
t.Fatal("removed 後 manifest 條目應移除")
}
if len(m.Entries) != 2 {
t.Fatalf("manifest 應剩 2 條: %+v", m.Entries)
}
}
// 情境 4renamed——改名/搬移以 content_hash 配對,只更新路徑映射:
// 不重萃(ingested_hash 原樣搬)、不誤刪(零 removed/added 事件)。
func TestRenamed(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "a.md", "same content\n", baseTime)
writeFile(t, root, "keep.md", "keep\n", baseTime)
m := newTestManifest()
mustScan(t, root, m)
markIngested(m)
ingestedAt := m.Entries["a.md"].IngestedAt
// 搬進子資料夾+改名(同內容)
writeFile(t, root, "sub/b.md", "same content\n", baseTime.Add(time.Hour))
if err := os.Remove(filepath.Join(root, "a.md")); err != nil {
t.Fatal(err)
}
p := mustScan(t, root, m)
if len(p.Events) != 1 {
t.Fatalf("改名只該有 1 個 renamed 事件,得到: %+v", p.Events)
}
ev := p.Events[0]
if ev.Type != "renamed" || ev.Path != "sub/b.md" || ev.OldPath != "a.md" || ev.SourceHash != hashOf("same content\n") {
t.Fatalf("renamed 事件不對: %+v", ev)
}
if len(eventsOfType(p, "removed"))+len(eventsOfType(p, "added")) != 0 {
t.Fatalf("改名不得產生 removed/added(誤刪/重萃): %+v", p.Events)
}
if _, ok := m.Entries["a.md"]; ok {
t.Fatal("舊路徑條目應消失")
}
ne := m.Entries["sub/b.md"]
if ne == nil || ne.IngestedHash != hashOf("same content\n") || ne.IngestedAt != ingestedAt {
t.Fatalf("路徑映射未保留 ingest 狀態(會導致重萃): %+v", ne)
}
}
// 情境 5:大量刪除防呆——資料夾整個清空(100% > 40%),卡片零下架+警告有發出,manifest 條目保留。
func TestMassDeleteGuard(t *testing.T) {
root := t.TempDir()
names := []string{"a.md", "b.md", "c.md", "d.md", "e.md"}
for _, n := range names {
writeFile(t, root, n, n+" content\n", baseTime)
}
m := newTestManifest()
mustScan(t, root, m)
markIngested(m)
for _, n := range names { // 模擬資料夾未掛載/清空
if err := os.Remove(filepath.Join(root, n)); err != nil {
t.Fatal(err)
}
}
p := mustScan(t, root, m)
if len(eventsOfType(p, "removed")) != 0 {
t.Fatalf("防呆觸發時 removed 必須全數壓下: %+v", p.Events)
}
if len(p.Warnings) != 1 || p.Warnings[0].Code != "mass_delete_guard" {
t.Fatalf("警告未發出: %+v", p.Warnings)
}
w := p.Warnings[0]
if w.RemovedCount != 5 || w.ManifestCount != 5 || w.ThresholdRatio != DefaultMaxRemovedRatio {
t.Fatalf("警告數字不對: %+v", w)
}
if len(m.Entries) != 5 {
t.Fatalf("防呆觸發時 manifest 條目必須保留(下輪重評): %d", len(m.Entries))
}
// 檔案回來(誤刪復原)→ 內容沒變、mtime 相同 → 零事件、警告消失。
for _, n := range names {
writeFile(t, root, n, n+" content\n", baseTime)
}
p2 := mustScan(t, root, m)
if len(p2.Events) != 0 || len(p2.Warnings) != 0 {
t.Fatalf("復原後應零事件零警告: events=%+v warnings=%+v", p2.Events, p2.Warnings)
}
}
// 附加:mtime fast-path——mtime/size 沒變不重算 hash(用「內容偷偷改但 mtime 不動」驗證 fast-path 真的生效)。
func TestMtimeFastPath(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "a.md", "AAAA\n", baseTime) // 與替換內容等長 → size 相同
m := newTestManifest()
mustScan(t, root, m)
markIngested(m)
// 同 size、同 mtime、內容不同:fast-path 應沿用舊 hash → 零事件(設計如此:mtime 只是 fast-path
writeFile(t, root, "a.md", "BBBB\n", baseTime)
p := mustScan(t, root, m)
if len(p.Events) != 0 {
t.Fatalf("fast-path 未生效(mtime/size 未變不該重算 hash: %+v", p.Events)
}
// mtime 一變就會重算 → 抓到真變更
writeFile(t, root, "a.md", "BBBB\n", baseTime.Add(time.Minute))
p2 := mustScan(t, root, m)
if len(p2.Events) != 1 || p2.Events[0].Type != "modified" {
t.Fatalf("mtime 變更後應抓到 modified: %+v", p2.Events)
}
}
// 附加:manifest 存讀往返。
func TestManifestRoundTrip(t *testing.T) {
dir := t.TempDir()
mp := filepath.Join(dir, "manifest.json")
m, err := LoadManifest(mp, "/tmp/kb")
if err != nil {
t.Fatal(err)
}
if m.FolderID == "" {
t.Fatal("新 manifest 應生成 folder_id")
}
m.Entries["x.md"] = &ManifestEntry{ContentHash: hashOf("x"), Size: 1, Mtime: 2, IngestedHash: hashOf("x"), IngestedAt: 3}
if err := m.Save(mp); err != nil {
t.Fatal(err)
}
m2, err := LoadManifest(mp, "")
if err != nil {
t.Fatal(err)
}
if m2.FolderID != m.FolderID || m2.Entries["x.md"] == nil || m2.Entries["x.md"].IngestedAt != 3 {
t.Fatalf("往返不一致: %+v", m2)
}
}