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>
This commit is contained in:
2026-08-16 21:35:22 +08:00
parent 345be49d4d
commit 895d672177
7 changed files with 941 additions and 45 deletions
+16
View File
@@ -40,6 +40,18 @@ type AccountSyncStatus struct {
QuotaMessage *QuotaNotice `json:"quota_message,omitempty"`
}
// RetiringStatus=某個「已移除、雲端撤除進行中」資料夾的現況(arcrun-rag#46)。
//
// 為什麼要有這個欄位:撤除可能跨好幾輪(節流+單輪上限+雲端可能剛好掛掉),
// 使用者按下「移除並收回」之後如果畫面什麼都不說,他會以為又是一顆沒作用的按鈕
// ——那正是這張票的病。⇒ 進度與**失敗的真因**都要看得見
// leo 2026-08-06:「別人的錯誤一律要顯示給用戶看,不然就會變成我的錯誤」)。
type RetiringStatus struct {
Remaining int `json:"remaining"` // 還有幾筆沒撤成功
Done bool `json:"done"` // 已經收乾淨(App 看到才把設定裡那一筆清掉)
LastError string `json:"last_error,omitempty"` // 最後一次失敗的真因(原文,不改寫)
}
// SyncStatus 彙總每輪同步的萃取結果,持久化至 ~/.arcrun-rag/status.json。
// 托盤依此決定顯示「已萃 N 檔」、「⚠ 萃取失敗 M 檔」還是「⚠ 萃取引擎未就緒」。
type SyncStatus struct {
@@ -67,6 +79,10 @@ type SyncStatus struct {
// t104per-account 狀態(key = instanceHostOf(cypher_url)
AccountDetails map[string]AccountSyncStatus `json:"account_details,omitempty"`
// arcrun-rag#46:「移除並收回中」的資料夾進度(key=資料夾路徑)。
// 與 SkippedDocs 同族——每輪照現況重算的快照,不進 CarryForwardActivity。
Retiring map[string]RetiringStatus `json:"retiring,omitempty"`
// 🔴 G-6.2「不准安靜地略過」(2026-08-06):副檔名不在 allowedExt 的檔案,
// 以前在 scan.go 的白名單閘就 `return nil` 蒸發了——沒事件、沒紀錄、沒畫面。
// 使用者丟一份 .doc 進資料夾,得到的回應是**完全的沉默**。