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
+110 -3
View File
@@ -60,8 +60,14 @@ type accountCfg struct {
Namespace string `json:"namespace"`
APIKey string `json:"api_key,omitempty"`
WatchFolders []string `json:"watch_folders,omitempty"`
Extractor string `json:"extractor,omitempty"`
GeminiAPIKey string `json:"gemini_api_key,omitempty"`
// RetiringFolders=已按「移除並收回」、雲端還沒撤乾淨的資料夾(arcrun-rag#46)。
//
// 🔴 t108 那條教訓的直接適用:saveCfg 會把整個 accounts 陣列**用這個 struct 重新序列化**
// ⇒ 這裡少一個欄位,寫在 config 裡的那一欄下次存檔就靜默消失(Go omitempty 直接不見)。
// 凡是 collector 的 AccountConfig 有、而 App 會改到的欄位,兩份必須鏡像。
RetiringFolders []string `json:"retiring_folders,omitempty"`
Extractor string `json:"extractor,omitempty"`
GeminiAPIKey string `json:"gemini_api_key,omitempty"`
}
type directConfig struct {
@@ -101,6 +107,9 @@ type syncStatus struct {
// 直接原樣接住,不重新定義一份會漂移的結構。key = instanceHostOf(cypher_url)
// (與 UIAccount.Host 同一套算法,見 shortHost)。
AccountDetails map[string]collector.AccountSyncStatus `json:"account_details,omitempty"`
// arcrun-rag#46:「移除並收回中」的資料夾進度(key=資料夾路徑)。
// 形狀定義在 collector/sync_status.go,這裡原樣接住不另定義一份會漂移的結構。
Retiring map[string]collector.RetiringStatus `json:"retiring,omitempty"`
}
type skippedDoc struct {
@@ -219,6 +228,10 @@ func saveCfg(c *directConfig) error {
type UIFolder struct {
Path string `json:"path"`
AccIdx int `json:"accIdx"`
// arcrun-rag#46:這個資料夾已經被移除,正在把雲端的資料收回來。
Retiring bool `json:"retiring,omitempty"`
RetireRemaining int `json:"retireRemaining,omitempty"` // 還剩幾筆
RetireError string `json:"retireError,omitempty"` // 失敗真因(原文,不改寫)
}
type UIAccount struct {
Name string `json:"name"`
@@ -469,11 +482,32 @@ func (a *App) GetState() UIState {
}
sync := loadSyncStatus()
// arcrun-rag#46collector 說收乾淨了的資料夾,這裡才真的從設定裡消失
// App 是 config.json 的唯一寫入者,見 pruneFinishedRetirements)。
if pruneFinishedRetirements(cfg, sync) {
if err := saveCfg(cfg); err != nil {
appLog("清理已收回的資料夾失敗:%v", err)
} else {
restartWatch()
}
}
for i, acc := range cfg.Accounts {
ui := UIAccount{Name: accountName(acc), Host: shortHost(acc.CypherURL), Email: acc.Email}
for _, f := range acc.WatchFolders {
ui.Folders = append(ui.Folders, UIFolder{Path: f, AccIdx: i})
}
// 收回中的資料夾照樣列出來,只是標成「收回中」——不然按下移除之後它立刻消失,
// 使用者無從知道撤除還在跑、更看不到失敗的原因(那正是這張票的病的另一面)。
for _, f := range acc.RetiringFolders {
uf := UIFolder{Path: f, AccIdx: i, Retiring: true}
if st, ok := sync.Retiring[f]; ok {
uf.RetireRemaining = st.Remaining
uf.RetireError = st.LastError
}
ui.Folders = append(ui.Folders, uf)
}
// t215per-account 雲端版本狀態——key 與 Host 同一套算法(shortHost),
// 對應 collector 寫入 status.json 時用的 instanceHostOf(兩者對一般 https URL 同值)。
if accSt, ok := sync.AccountDetails[ui.Host]; ok {
@@ -647,6 +681,14 @@ func (a *App) AddFolder(accIdx int, path string) error {
return nil // 已經在看守了,不重複加
}
}
// arcrun-rag#46:正在收回中的資料夾不能同時又加回來看守——那會變成
// 「一邊撤除、一邊重新上傳同一批檔」,兩條路互相打架,結果不可預測。
// 擋一次比事後對帳容易解釋,訊息要告訴使用者現在是什麼狀況、該怎麼辦。
for _, f := range cfg.Accounts[accIdx].RetiringFolders {
if f == path {
return fmt.Errorf("這個資料夾正在從雲端收回資料,等它收完再加回來(可在畫面上看到進度)")
}
}
cfg.Accounts[accIdx].WatchFolders = append(cfg.Accounts[accIdx].WatchFolders, path)
sort.Strings(cfg.Accounts[accIdx].WatchFolders)
if err := saveCfg(cfg); err != nil {
@@ -656,7 +698,28 @@ func (a *App) AddFolder(accIdx int, path string) error {
return nil
}
func (a *App) RemoveFolder(accIdx int, path string) error {
// RemoveFolder 把資料夾從清單移除。
//
// 🔴 arcrun-rag#46leo 2026-08-16 實撞):「我去把 Logseq plugin 刪掉以後,
//
// **採集的 wiki 沒消失**。」——移除之後那個資料夾的內容在雲端一筆都沒少,
// 照樣搜得到、照樣是已嵌入狀態、AI 照樣拿它回答。
//
// 真兇:這支函式原本只做三件事(從 WatchFolders 拿掉、存檔、重啟看守),
// **一次都沒碰撤除**。撤除的能力本身是好的、有測試、也真的被部署,只是
// 「整個資料夾從清單移除」這條路從來不呼叫它——
// **在使用者眼裡是同一件事(我不要這份資料了),在程式裡是兩條完全不同的路。**
//
// takedown=使用者在對話框上明確選的那一個:
// - true :連同雲端已經整理好的知識一起收回(資料夾搬進 retiring_folders
// 由 collector 逐筆撤除;進度與失敗原因走 status.json 回到畫面)
// - false:只停止同步,雲端保留(=這支函式原本的行為)
//
// 為什麼做成使用者選、而不是我們替他決定:兩種都是合理的需求(換電腦/重整資料夾
// vs 我不要這份資料了),而**猜錯任何一邊都是不可逆的**——猜「保留」則產品承諾的
// 「資料所有權完全屬於使用者」是假的;猜「收回」則整理好的知識被誤刪。
// ⇒ 在動作的當下把兩個後果講清楚、讓他自己挑(見前端 confirmRemove 的文案)。
func (a *App) RemoveFolder(accIdx int, path string, takedown bool) error {
cfg, err := loadCfg()
if err != nil {
return err
@@ -665,19 +728,63 @@ func (a *App) RemoveFolder(accIdx int, path string) error {
return fmt.Errorf("找不到這個知識庫帳號")
}
keep := []string{}
found := false
for _, f := range cfg.Accounts[accIdx].WatchFolders {
if f != path {
keep = append(keep, f)
} else {
found = true
}
}
cfg.Accounts[accIdx].WatchFolders = keep
if takedown && found {
// 只在「本來真的在看守」時排撤除——否則重複按會排出一堆重複待辦。
already := false
for _, f := range cfg.Accounts[accIdx].RetiringFolders {
if f == path {
already = true
}
}
if !already {
cfg.Accounts[accIdx].RetiringFolders = append(cfg.Accounts[accIdx].RetiringFolders, path)
}
}
if err := saveCfg(cfg); err != nil {
return err
}
restartWatch()
if takedown {
// 不必等下一輪輪詢——使用者剛按下按鈕,他期待「現在就開始」。
// 沿用既有的 sync-now 訊號檔,不新發明一套 IPC。
_ = a.SyncNow()
}
return nil
}
// pruneFinishedRetirements 把「collector 已回報收乾淨」的資料夾從設定裡清掉。
//
// 為什麼由 App 清而不是 collector 自己清:config.json 的寫入者只有 App 一個,
// 兩個行程都寫同一個檔=互相蓋掉對方的設定(t108 那類靜默掉欄位的病的另一種形狀)。
// collector 只在 status.json 說「這個根收乾淨了」,且**每輪都照現況重說**
// level-triggered)——App 關著沒看到也不會卡住,下次開起來照樣清得掉。
func pruneFinishedRetirements(cfg *directConfig, sync syncStatus) bool {
changed := false
for i := range cfg.Accounts {
keep := cfg.Accounts[i].RetiringFolders[:0:0]
for _, f := range cfg.Accounts[i].RetiringFolders {
if st, ok := sync.Retiring[f]; ok && st.Done {
changed = true
continue
}
keep = append(keep, f)
}
if len(keep) != len(cfg.Accounts[i].RetiringFolders) {
cfg.Accounts[i].RetiringFolders = keep
}
}
return changed
}
// SetAI 存 AI 設定。
// 🔴 t190:金鑰**無條件以輸入框為準**(清空=刪除)——leo 實撞過「金鑰刪不掉」。
func (a *App) SetAI(useGemini bool, key string) error {
+26 -4
View File
@@ -268,7 +268,14 @@ function pageLib(s, idx) {
<button class="primary" data-addto="${idx}">加入資料夾</button>
</div>
<div class="kbver">${kbVersionLine(a)}</div>
${(a.folders || []).map((f) => `
${(a.folders || []).map((f) => f.retiring ? `
<div class="folder">
<span class="path" title="${esc(f.path)}">${esc(f.path)}</span>
<span class="tag retiring">${f.retireError
? '收回時出錯,會自動再試'
: `正在從雲端收回…${f.retireRemaining ? `還有 ${f.retireRemaining}` : ''}`}</span>
</div>
${f.retireError ? `<div class="d folder-note">${esc(f.retireError)}</div>` : ''}` : `
<div class="folder">
<span class="path" title="${esc(f.path)}">${esc(f.path)}</span>
<span class="tag">自動同步中</span>
@@ -467,15 +474,30 @@ async function addFolder(accIdx) {
state = await go.GetState(); renderNav(); renderPage();
}
// 移除資料夾=兩個後果完全不同的動作,所以給兩顆按鈕,不給一顆猜。
//
// 🔴 arcrun-rag#46leo 2026-08-16 實撞):「我去把 Logseq plugin 刪掉以後,
// **採集的 wiki 沒消失**。」舊文案寫的是「已經上傳的知識卡不會被刪除」——
// 那句話**在技術上是對的**,但它預設使用者要的是「只停止同步」,
// 而他要的是「我不要這份資料了」。⇒ 病不在少一句說明,在**替他決定了**。
// 現在兩個選擇都擺出來、後果各寫一行,由他挑。
function confirmRemove(accIdx, path) {
openSheet(`
<h2>移除這個資料夾?</h2>
<p>「${esc(path)}不再自動同步。<br/>已經上傳的知識卡不會被刪除。</p>
<div class="acts"><button id="c1">取消</button><button class="primary" id="c2">移除</button></div>`,
<p>「${esc(path)}要怎麼處理?<b>你電腦裡的原始檔案不會被動到</b>,兩個選擇的差別只在雲端。</p>
<label class="radio"><input type="radio" name="rmMode" value="takedown" checked/>
<span><b>連同雲端的知識一起收回</b><br/>
<span class="d">這個資料夾整理出來的知識會從知識庫刪除,之後搜尋找不到、AI 也不會再拿它回答。<b>刪掉就要不回來</b>。</span></span></label>
<label class="radio"><input type="radio" name="rmMode" value="unwatch"/>
<span><b>只停止同步,雲端的知識保留</b><br/>
<span class="d">以後這個資料夾有變動不會再上傳,但之前整理好的知識留在知識庫裡,搜尋和 AI 照樣找得到。</span></span></label>
<div class="acts"><button id="c1">取消</button><button class="primary" id="c2">確定</button></div>`,
() => {
$('c1').onclick = closeSheet;
$('c2').onclick = async () => {
await go.RemoveFolder(accIdx, path); closeSheet();
const mode = document.querySelector('input[name="rmMode"]:checked');
const takedown = !mode || mode.value === 'takedown';
await go.RemoveFolder(accIdx, path, takedown); closeSheet();
state = await go.GetState(); renderNav(); renderPage();
};
});
+3
View File
@@ -140,6 +140,9 @@ button.ghost:hover { color: var(--err); background: transparent; }
}
.folder .path { flex: 1; min-width: 0; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.folder .tag { font-size: 12px; color: var(--ok); white-space: nowrap; }
/* arcrun-rag#46:收回中=進行中的狀態,不是「一切正常」,所以不用綠色。 */
.folder .tag.retiring { color: rgba(var(--ink-rgb), .5); }
.folder-note { margin: -3px 0 9px; padding: 0 15px; font-size: 12.5px; }
.big-num { font-size: 26px; font-weight: 600; letter-spacing: .01em; }
.kv { display: flex; gap: 26px; margin-top: 4px; }
@@ -0,0 +1,151 @@
// remove_folder_takedown_test.go — arcrun-rag#46:按下「移除」之後,資料真的要被收回。
//
// leo 2026-08-16 實撞:「我去把 Logseq plugin 刪掉以後,**採集的 wiki 沒消失**。」
// 真兇:RemoveFolder 只把路徑從 WatchFolders 拿掉、存檔、重啟看守——**一次都沒碰撤除**。
//
// 本檔釘 App 這一半的三件事:
// 1. 選了「連同雲端一起收回」⇒ 資料夾要進 retiring_folderscollector 靠它才知道要撤)
// 2. 選了「只停止同步」⇒ 行為與從前一字不差(不可以偷偷幫使用者刪東西)
// 3. collector 回報收乾淨了 ⇒ 設定裡那一筆才消失(且中途不會被靜默丟掉)
package main
import (
"encoding/json"
"os"
"testing"
collector "arcrun-rag/collector"
)
func newTestCfgWithFolder(t *testing.T, path string) {
t.Helper()
cfg := &directConfig{
Accounts: []accountCfg{{
CypherURL: "https://example.workers.dev", Namespace: "abc123", APIKey: "abc123",
WatchFolders: []string{path, "/other/folder"},
}},
Extractor: "workers-ai",
}
if err := saveCfg(cfg); err != nil {
t.Fatalf("存檔失敗:%v", err)
}
}
// ① 選「連同雲端一起收回」:離開看守清單、進入待撤清單。
func TestRemoveFolderWithTakedownQueuesRetirement(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
newTestCfgWithFolder(t, "/kb/logseq-plugin")
if err := (&App{}).RemoveFolder(0, "/kb/logseq-plugin", true); err != nil {
t.Fatalf("移除失敗:%v", err)
}
cfg, err := loadCfg()
if err != nil {
t.Fatal(err)
}
for _, f := range cfg.Accounts[0].WatchFolders {
if f == "/kb/logseq-plugin" {
t.Error("移除後不該還在看守清單裡")
}
}
if len(cfg.Accounts[0].RetiringFolders) != 1 || cfg.Accounts[0].RetiringFolders[0] != "/kb/logseq-plugin" {
t.Fatalf("🔴 這就是 #46 的真兇:移除沒有排任何撤除,retiring_folders=%v",
cfg.Accounts[0].RetiringFolders)
}
// 別的資料夾不能被波及
if len(cfg.Accounts[0].WatchFolders) != 1 || cfg.Accounts[0].WatchFolders[0] != "/other/folder" {
t.Errorf("其他資料夾被動到了:%v", cfg.Accounts[0].WatchFolders)
}
// 🔴 t108 鏡像檢查:欄位要真的落在磁碟上、且 collector 讀得回來。
// (兩份 struct 各自維護,少一欄就會在下次存檔靜默消失。)
raw, _ := os.ReadFile(configPath())
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
accs, _ := m["accounts"].([]any)
if len(accs) == 0 {
t.Fatal("accounts 不見了")
}
a0, _ := accs[0].(map[string]any)
if _, ok := a0["retiring_folders"]; !ok {
t.Error("retiring_folders 沒有寫進磁碟 ⇒ collector 永遠不會知道要撤除")
}
cc, err := collector.LoadDirectConfig(configPath())
if err != nil {
t.Fatalf("collector 讀不了 App 存的 config%v", err)
}
if got := cc.RetiringRoots(); len(got) != 1 || got[0] != "/kb/logseq-plugin" {
t.Errorf("collector 端看到的待撤清單=%v", got)
}
}
// ② 選「只停止同步」:行為與從前一字不差——不可以幫使用者做他沒選的刪除。
func TestRemoveFolderWithoutTakedownKeepsCloudData(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
newTestCfgWithFolder(t, "/kb/keep-cloud")
if err := (&App{}).RemoveFolder(0, "/kb/keep-cloud", false); err != nil {
t.Fatalf("移除失敗:%v", err)
}
cfg, _ := loadCfg()
for _, f := range cfg.Accounts[0].WatchFolders {
if f == "/kb/keep-cloud" {
t.Error("移除後不該還在看守清單裡")
}
}
if len(cfg.Accounts[0].RetiringFolders) != 0 {
t.Errorf("使用者選的是「保留雲端資料」,不該排撤除:%v", cfg.Accounts[0].RetiringFolders)
}
}
// ③ 收乾淨了才從設定裡消失;還在撤的中途不可以被清掉(清掉=待辦永久遺失)。
func TestRetirementClearedOnlyWhenCollectorSaysDone(t *testing.T) {
cfg := &directConfig{Accounts: []accountCfg{{
RetiringFolders: []string{"/kb/finished", "/kb/still-going", "/kb/never-reported"},
}}}
sync := syncStatus{Retiring: map[string]collector.RetiringStatus{
"/kb/finished": {Done: true},
"/kb/still-going": {Remaining: 12, LastError: "HTTP 500boom"},
// "/kb/never-reported" 刻意沒被回報(collector 還沒跑到)
}}
if !pruneFinishedRetirements(cfg, sync) {
t.Fatal("有一筆已完成,應回報有變動")
}
got := cfg.Accounts[0].RetiringFolders
if len(got) != 2 || got[0] != "/kb/still-going" || got[1] != "/kb/never-reported" {
t.Fatalf("只有 done 的那一筆該消失,實得 %v", got)
}
// 冪等:再跑一次不該有變動(不然每輪都在寫 config)
if pruneFinishedRetirements(cfg, sync) {
t.Error("沒有新的完成項時不該回報變動")
}
}
// ④ 收回中的資料夾不准同時加回來看守——一邊撤一邊傳,結果不可預測。
func TestAddFolderRefusedWhileRetiring(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
cfg := &directConfig{Accounts: []accountCfg{{
CypherURL: "https://example.workers.dev", Namespace: "abc123",
RetiringFolders: []string{"/kb/retiring"},
}}}
if err := saveCfg(cfg); err != nil {
t.Fatal(err)
}
if err := (&App{}).AddFolder(0, "/kb/retiring"); err == nil {
t.Error("正在收回的資料夾應該擋下來並說明原因,不該默默加回去")
}
after, _ := loadCfg()
if len(after.Accounts[0].WatchFolders) != 0 {
t.Errorf("被擋下就不該寫進看守清單:%v", after.Accounts[0].WatchFolders)
}
}