Files
arcrun-collector/direct_retire_test.go
Leo 895d672177 fix(daemon): 移除資料夾要真的把雲端資料收回(arcrun-rag#46)
leo 2026-08-16 實撞:掛資料夾、同步完成、從清單按「移除」之後——
「我去把 Logseq plugin 刪掉以後,**採集的 wiki 沒消失**」。
內容一筆都沒少,照樣搜得到、照樣 is_embedded=1、AI 照樣拿它回答。

真兇(源碼):app.go 的 RemoveFolder 全文只做三件事——從 WatchFolders 拿掉、
saveCfg、restartWatch,**一次都沒碰撤除**。而撤除機制本身是好的(在部署白名單裡、
有測試、direct.go 真的會觸發它),只是那兩個觸發點都在「還在監看的資料夾裡某個檔
被刪掉」的差異偵測迴圈裡。⇒ 刪一個檔會撤除 /移除整個資料夾不會 。
**在使用者眼裡是同一件事,在程式裡是兩條完全不同的路,只有一條接上了撤除。**

這不只是少一個功能:產品說明卡寫著「確保資料所有權完全屬於使用者而非 SaaS
供應商」,而使用者唯一看得到的收回動作不收回任何東西 ⇒ 知情同意的問題。

修法(沿用既有那條撤除路,不另寫一份):
- drainPendingTakedowns:把既有的「待下架清單逐筆送出」抽成共用函式
- retireRootOnce:資料夾進 retiring_folders 後,把帳本裡真的上傳過的檔排進
  既有的 PendingTakedowns、走同一條撤除路;撤乾淨才刪帳本
- 進度與失敗真因寫進 status.json(level-triggered),App 看到 done 才清設定
- App 是 config.json 的唯一寫入者(兩個行程都寫=互相蓋掉對方的設定)

邊界(本票最危險的地方):path 是相對於被監看資料夾的路徑 ⇒ 兩個資料夾各有
notes.md 時 page_name 與 path 完全相同,撤除一個會連坐另一個。撤除 payload 帶
library(逐根導出,與 ingest 同一個函式算的),workflow 兩個比對節點加「library
相符才殺」。只在兩邊都有 library 時才收緊 ⇒ 舊 daemon 不送/舊卡沒有都退回原行為。

畫面:舊文案「已經上傳的知識卡不會被刪除」技術上是對的,但它替使用者決定了他要的
是「只停止同步」。改成兩個選項各寫一行後果讓他選(預設待 leo 裁)。

測試:go test ./collector/... ./collector/cmd/arcrun-app/... 全綠;
新增 direct_retire_test.go(7)/remove_folder_takedown_test.go(4)/
workflows/tests/takedown-scope.test.mjs(8)。UI 用真 dist + headless Chrome 複驗,
check-cis.sh/check-render.sh 全過。

◐ 未做:真實例端到端(不可逆且 leo 正在該機器上工作,步驟已寫成清單等總管確認)/
workflow 要重新部署才生效/未重打 bundle、未出貨。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:35:22 +08:00

405 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// direct_retire_test.go — arcrun-rag#46:「把整個資料夾從清單移除」要真的把雲端資料收回。
//
// 病(leo 2026-08-16 實撞):「我去把 Logseq plugin 刪掉以後,**採集的 wiki 沒消失**。」
// 真兇是 App 的 RemoveFolder 只把路徑從看守清單拿掉,一次都沒碰撤除——
// 撤除機制本身是好的(有測試、有部署),只是這條路從來不呼叫它。
//
// 本檔釘的是 collector 這一半:資料夾進了 retiring_folders 之後,
// ① 真的逐筆撤除、② 撤乾淨才算完成、③ **不會波及別的資料夾**(巢狀/同名的邊界)。
package collector
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
)
// takedownRecorder 收下每一次撤除呼叫的 payload,讓測試能斷言「殺的是哪一份」。
type takedownRecorder struct {
mu sync.Mutex
hits []map[string]any
fail bool // true=一律回 500,用來驗「失敗要看得出來、而且下輪還會再試」
}
func (rec *takedownRecorder) server() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var m map[string]any
_ = json.Unmarshal(body, &m)
rec.mu.Lock()
rec.hits = append(rec.hits, m)
rec.mu.Unlock()
if rec.fail {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"boom"}`))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
}
func (rec *takedownRecorder) paths() []string {
rec.mu.Lock()
defer rec.mu.Unlock()
var out []string
for _, h := range rec.hits {
if p, ok := h["path"].(string); ok {
out = append(out, p)
}
}
return out
}
// seedSyncedRoot 造一個「已經同步過」的資料夾:實體檔案+標成 ingested 的帳本。
func seedSyncedRoot(t *testing.T, cfg *DirectConfig, root string, files ...string) string {
t.Helper()
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
absRoot, _ := filepath.Abs(root)
mp := cfg.manifestPathFor(absRoot)
m := &Manifest{FolderID: "fid-" + filepath.Base(root), Root: absRoot, Entries: map[string]*ManifestEntry{}}
for _, f := range files {
if err := os.WriteFile(filepath.Join(root, f), []byte("# "+f), 0o644); err != nil {
t.Fatal(err)
}
m.Entries[f] = &ManifestEntry{ContentHash: "h-" + f, IngestedHash: "h-" + f, IngestedAt: 1}
}
if err := m.Save(mp); err != nil {
t.Fatal(err)
}
return mp
}
// ① 主線:資料夾進了 retiring_folders ⇒ 每個已上傳的檔都被撤除,帳本收乾淨。
func TestRetiringFolderTakesDownEveryIngestedFile(t *testing.T) {
rec := &takedownRecorder{}
srv := rec.server()
defer srv.Close()
base := t.TempDir()
root := filepath.Join(base, "logseq-plugin")
cfg := &DirectConfig{
Manifest: filepath.Join(base, "manifest.json"),
CypherURL: srv.URL, Namespace: "ns1", APIKey: "ns1",
RetiringFolders: []string{root},
MaxRemoved: DefaultMaxRemovedRatio,
ExtractorExplicit: true, // 隔離變因:走舊制直送,不打雲端萃取端點
}
mp := seedSyncedRoot(t, cfg, root, "a.md", "b.md", "notes.md")
results, exit, remaining, done := retireRootOnce(cfg, root, false)
if exit != 0 {
t.Fatalf("撤除應成功,exit=%d results=%+v", exit, results)
}
if !done || remaining != 0 {
t.Fatalf("三筆都撤成功就該收乾淨,done=%v remaining=%d", done, remaining)
}
got := rec.paths()
if len(got) != 3 {
t.Fatalf("三個已上傳的檔應各撤一次,實得 %d 筆:%v", len(got), got)
}
want := map[string]bool{"a.md": true, "b.md": true, "notes.md": true}
for _, p := range got {
if !want[p] {
t.Errorf("撤除了不該撤的 %q", p)
}
}
// 收乾淨了 ⇒ 帳本沒有存在的理由,且下一輪不會再重排一次
if _, err := os.Stat(mp); !os.IsNotExist(err) {
t.Errorf("收乾淨後帳本該被刪掉,err=%v", err)
}
before := len(rec.paths())
if _, _, _, done2 := retireRootOnce(cfg, root, false); !done2 {
t.Error("第二輪應直接回報已完成")
}
if len(rec.paths()) != before {
t.Errorf("第二輪不該再打任何撤除(冪等),before=%d after=%d", before, len(rec.paths()))
}
}
// ②(最重要)邊界:兩個資料夾放著同名同相對路徑的檔,移除其中一個
//
// **不可以**碰到另一個。程式這一半的保證=撤除只針對那個根的帳本、
// 且 payload 帶著逐根導出的 library 讓雲端分得開。
func TestRetiringOneFolderDoesNotTouchTheOther(t *testing.T) {
rec := &takedownRecorder{}
srv := rec.server()
defer srv.Close()
base := t.TempDir()
gone := filepath.Join(base, "gone") // 要移除的
keep := filepath.Join(base, "keep") // 還在用的
cfg := &DirectConfig{
Manifest: filepath.Join(base, "manifest.json"),
CypherURL: srv.URL, Namespace: "ns1", APIKey: "ns1",
WatchFolders: []string{keep},
RetiringFolders: []string{gone},
MaxRemoved: DefaultMaxRemovedRatio,
ExtractorExplicit: true,
}
// 兩邊都有 notes.mdpage_name 與相對 path 完全相同——這正是會誤傷的組合。
goneMp := seedSyncedRoot(t, cfg, gone, "notes.md", "only-in-gone.md")
keepMp := seedSyncedRoot(t, cfg, keep, "notes.md", "only-in-keep.md")
if _, exit, _, done := retireRootOnce(cfg, gone, false); exit != 0 || !done {
t.Fatalf("撤除應成功且收乾淨,exit=%d done=%v", exit, done)
}
// (a) 還在用的那個資料夾的帳本必須毫髮無傷
keepM, err := LoadManifest(keepMp, keep)
if err != nil {
t.Fatalf("讀不回還在用的帳本:%v", err)
}
if len(keepM.Entries) != 2 {
t.Errorf("還在用的資料夾帳本被動到了:%+v", keepM.Entries)
}
if len(keepM.PendingTakedowns) != 0 {
t.Errorf("還在用的資料夾不該有待撤除:%+v", keepM.PendingTakedowns)
}
if _, err := os.Stat(keepMp); err != nil {
t.Errorf("還在用的資料夾帳本不該被刪:%v", err)
}
if _, err := os.Stat(filepath.Join(keep, "notes.md")); err != nil {
t.Errorf("還在用的資料夾的檔案不該被刪:%v", err)
}
if _, err := os.Stat(goneMp); !os.IsNotExist(err) {
t.Errorf("被移除的資料夾帳本該收掉,err=%v", err)
}
// (b) 送上雲的每一筆都要帶 library,且是**被移除那個資料夾**的 library——
// 沒有這一維,雲端無從分辨兩個 notes.mdpage_name 與 path 全同)。
goneLib := cfg.libraryFor(mustAbs(t, gone))
keepLib := cfg.libraryFor(mustAbs(t, keep))
if goneLib == keepLib {
t.Fatalf("測試前提壞了:兩個資料夾應導出不同 library,都是 %q", goneLib)
}
rec.mu.Lock()
hits := append([]map[string]any(nil), rec.hits...)
rec.mu.Unlock()
if len(hits) != 2 {
t.Fatalf("只該撤被移除那個資料夾的兩個檔,實得 %d:%v", len(hits), rec.paths())
}
for _, h := range hits {
if h["library"] != goneLib {
t.Errorf("撤除 payload 的 library=%v,應為被移除資料夾的 %q(否則雲端會誤殺同名檔)",
h["library"], goneLib)
}
}
}
// ③ 中途失敗要看得出來,而且不會被當成「已收乾淨」默默丟掉。
func TestRetiringFailureStaysPendingAndVisible(t *testing.T) {
rec := &takedownRecorder{fail: true}
srv := rec.server()
defer srv.Close()
base := t.TempDir()
root := filepath.Join(base, "flaky")
cfg := &DirectConfig{
Manifest: filepath.Join(base, "manifest.json"),
CypherURL: srv.URL, Namespace: "ns1", APIKey: "ns1",
RetiringFolders: []string{root},
MaxRemoved: DefaultMaxRemovedRatio,
ExtractorExplicit: true,
}
mp := seedSyncedRoot(t, cfg, root, "x.md", "y.md")
results, exit, remaining, done := retireRootOnce(cfg, root, false)
if exit == 0 {
t.Error("雲端回 500 時 exit 不該是 0——失敗要浮上來")
}
if done {
t.Error("沒撤成功就宣告收乾淨=把資料留在雲端卻跟使用者說刪了")
}
if remaining != 2 {
t.Errorf("兩筆都失敗,remaining 應為 2,實得 %d", remaining)
}
var sawFail bool
for _, r := range results {
if r.Status == "failed" && r.Error != "" {
sawFail = true
}
}
if !sawFail {
t.Errorf("失敗要帶原因(畫面要說得出來),results=%+v", results)
}
if _, err := os.Stat(mp); err != nil {
t.Errorf("還沒撤乾淨,帳本不可以刪掉(刪了=待辦永久遺失):%v", err)
}
m, err := LoadManifest(mp, root)
if err != nil {
t.Fatal(err)
}
if len(m.PendingTakedowns) != 2 {
t.Errorf("失敗的兩筆要留在待辦清單等下輪重試,實得 %+v", m.PendingTakedowns)
}
// 雲端恢復 ⇒ 下一輪自己補完,不需要使用者再按一次
rec.mu.Lock()
rec.fail = false
rec.mu.Unlock()
if _, exit2, remaining2, done2 := retireRootOnce(cfg, root, false); exit2 != 0 || !done2 || remaining2 != 0 {
t.Errorf("雲端恢復後應自動補完,exit=%d remaining=%d done=%v", exit2, remaining2, done2)
}
}
// ④ 沒成功上傳過的檔不必空打——雲端根本沒有它。
func TestRetiringSkipsNeverIngestedFiles(t *testing.T) {
rec := &takedownRecorder{}
srv := rec.server()
defer srv.Close()
base := t.TempDir()
root := filepath.Join(base, "half")
cfg := &DirectConfig{
Manifest: filepath.Join(base, "manifest.json"),
CypherURL: srv.URL, Namespace: "ns1", APIKey: "ns1",
RetiringFolders: []string{root},
MaxRemoved: DefaultMaxRemovedRatio,
ExtractorExplicit: true,
}
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
absRoot, _ := filepath.Abs(root)
m := &Manifest{FolderID: "fid", Root: absRoot, Entries: map[string]*ManifestEntry{
"up.md": {ContentHash: "h1", IngestedHash: "h1"}, // 上去過
"stuck.md": {ContentHash: "h2"}, // 從沒成功上去(失敗退避中)
"never.md": {ContentHash: "h3"}, // 同上
}}
if err := m.Save(cfg.manifestPathFor(absRoot)); err != nil {
t.Fatal(err)
}
if _, exit, _, done := retireRootOnce(cfg, root, false); exit != 0 || !done {
t.Fatalf("exit=%d done=%v", exit, done)
}
got := rec.paths()
if len(got) != 1 || got[0] != "up.md" {
t.Errorf("只該撤真的上去過的那一筆,實得 %v", got)
}
}
// ⑤ RetiringRoots() 兩層都要收——t149 的病(只讀了其中一層 ⇒ 新制設定被靜默忽略)。
func TestRetiringRootsReadsBothLayers(t *testing.T) {
c := &DirectConfig{
RetiringFolders: []string{"/tmp/legacy"},
Accounts: []AccountConfig{
{CypherURL: "https://a.example", RetiringFolders: []string{"/tmp/acc-a"}},
{CypherURL: "https://b.example", RetiringFolders: []string{"/tmp/acc-b", "/tmp/legacy"}},
},
}
got := c.RetiringRoots()
want := []string{"/tmp/legacy", "/tmp/acc-a", "/tmp/acc-b"}
if len(got) != len(want) {
t.Fatalf("RetiringRoots()=%v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("RetiringRoots()[%d]=%s want %s", i, got[i], want[i])
}
}
// 逐帳號隔離:撤除要打對的實例,不能沿用頂層清單
sub := c.makeAccountSubConfig(c.Accounts[0])
if got := sub.RetiringRoots(); len(got) != 1 || got[0] != "/tmp/acc-a" {
t.Fatalf("帳號子設定的 RetiringRoots()=%v,應只有該帳號自己的", got)
}
}
// ⑥ 端到端接線:RunDirectOnce 真的會處理 retiring_folders,並把進度寫進 status.json
//
// (畫面靠它顯示「正在收回…」與失敗原因;沒接上就等於整條路沒接)。
func TestRunDirectOnceDrivesRetirementAndReportsStatus(t *testing.T) {
rec := &takedownRecorder{}
srv := rec.server()
defer srv.Close()
origVer := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "stub", true }
defer func() { fetchCloudVersion = origVer }()
base := t.TempDir()
root := filepath.Join(base, "retired")
cfg := &DirectConfig{
Manifest: filepath.Join(base, "manifest.json"),
Accounts: []AccountConfig{{
CypherURL: srv.URL, Namespace: "ns1", APIKey: "ns1",
RetiringFolders: []string{root},
}},
MaxRemoved: DefaultMaxRemovedRatio,
ExtractorExplicit: true,
}
// 帳本路徑含**帳號**尾碼(t86b),所以要用帳號子設定去算,不能用頂層 cfg
// (頂層沒有 cypher_url ⇒ 算出來是另一個檔,等於什麼都沒 seed)。
seedSyncedRoot(t, cfg.makeAccountSubConfig(cfg.Accounts[0]), root, "one.md")
results, exit, _ := RunDirectOnce(cfg, false)
if exit != 0 {
t.Fatalf("exit=%d results=%+v", exit, results)
}
if got := rec.paths(); len(got) != 1 || got[0] != "one.md" {
t.Fatalf("RunDirectOnce 應驅動撤除,實得 %v", got)
}
var tagged bool
for _, r := range results {
if r.Type == "folder_takedown" && r.Root == root && r.Status == "removed" {
tagged = true
}
}
if !tagged {
t.Errorf("撤除結果應標明是哪個資料夾,results=%+v", results)
}
st, err := LoadSyncStatus(StatusFilePath(cfg.Manifest))
if err != nil {
t.Fatalf("讀不回 status.json%v", err)
}
rs, ok := st.Retiring[root]
if !ok {
t.Fatalf("status.json 應回報這個資料夾的收回進度,實得 %+v", st.Retiring)
}
if !rs.Done || rs.Remaining != 0 {
t.Errorf("撤乾淨後應回報 done(App 靠它把設定裡那一筆清掉),實得 %+v", rs)
}
}
func mustAbs(t *testing.T, p string) string {
t.Helper()
a, err := filepath.Abs(p)
if err != nil {
t.Fatal(err)
}
return a
}
// ⑦ 舊制(頂層 retiring_folders、沒有 accounts[])也要撤得掉。
//
// LoadDirectConfig 會把舊制包成 Accounts[0],而 makeAccountSubConfig 用帳號層
// 覆蓋頂層 ⇒ 遷移時漏帶這一欄,撤除就永遠不會發生、且**沒有任何錯誤訊息**(t149 形狀)。
func TestLegacyTopLevelRetiringFoldersMigrated(t *testing.T) {
dir := t.TempDir()
p := writeDirectConfig(t, dir, map[string]any{
"watch_folder": "/tmp/kb", "retiring_folders": []string{"/tmp/gone"},
"manifest": filepath.Join(dir, "m.json"),
"cypher_url": "https://x.example", "namespace": "demo",
})
c, err := LoadDirectConfig(p)
if err != nil {
t.Fatal(err)
}
if len(c.Accounts) != 1 {
t.Fatalf("舊制應被包成一個帳號,實得 %d", len(c.Accounts))
}
sub := c.makeAccountSubConfig(c.Accounts[0])
if got := sub.RetiringRoots(); len(got) != 1 || got[0] != "/tmp/gone" {
t.Fatalf("舊制的待撤清單沒被帶進帳號層 ⇒ 永遠撤不掉,實得 %v", got)
}
}