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
+231 -38
View File
@@ -24,6 +24,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -46,6 +47,11 @@ type AccountConfig struct {
APIKey string `json:"api_key,omitempty"`
WatchFolders []string `json:"watch_folders,omitempty"` // 此帳號看守的資料夾(多根)
Libraries map[string]string `json:"libraries,omitempty"` // 資料夾→庫對映(t52
// RetiringFolders=使用者已經按下「移除並收回雲端資料」、但雲端還沒撤乾淨的資料夾
// arcrun-rag#46)。**App 是唯一寫入者**:它把路徑從 WatchFolders 搬到這裡;
// collector 只讀,撤乾淨後在 status.json 把該根標成 done,由 App 負責清掉這一筆。
// 一個寫入者=不會有兩個行程互相蓋掉對方的設定。
RetiringFolders []string `json:"retiring_folders,omitempty"`
// t126:每帳號獨立的萃取設定(空值繼承 DirectConfig 頂層)
Extractor string `json:"extractor,omitempty"`
GeminiAPIKey string `json:"gemini_api_key,omitempty"`
@@ -59,6 +65,8 @@ type DirectConfig struct {
Accounts []AccountConfig `json:"accounts,omitempty"`
WatchFolder string `json:"watch_folder,omitempty"` // 監看的知識資料夾(單數舊制;與 watch_folders 至少填一)
WatchFolders []string `json:"watch_folders,omitempty"` // 監看的知識資料夾清單(daemon-beta task 1 多資料夾)
// RetiringFolders=舊制(單帳號)的「移除並收回中」清單;新制走 Accounts[].RetiringFolders。
RetiringFolders []string `json:"retiring_folders,omitempty"`
Manifest string `json:"manifest"` // manifest JSON 路徑(必填;多資料夾時為基底名,每根一份帶尾碼)
CypherURL string `json:"cypher_url,omitempty"` // 實例 cypher base(舊制;新制走 Accounts
Namespace string `json:"namespace,omitempty"` // 租戶 namespace(舊制;新制走 Accounts
@@ -187,6 +195,11 @@ func LoadDirectConfig(path string) (*DirectConfig, error) {
for j, p := range c.Accounts[i].WatchFolders {
c.Accounts[i].WatchFolders[j] = expandHome(p)
}
// arcrun-rag#46:待撤資料夾同樣要展開 `~/`——它與 WatchFolders 是同一種東西
// (使用者選的路徑),漏掉這裡就會去找一個字面上叫 "~" 的資料夾(t39 那個病)。
for j, p := range c.Accounts[i].RetiringFolders {
c.Accounts[i].RetiringFolders[j] = expandHome(p)
}
}
// t104:舊格式遷移——頂層 CypherURL → Accounts[0](冪等:有 Accounts 就跳過)
@@ -199,6 +212,10 @@ func LoadDirectConfig(path string) (*DirectConfig, error) {
APIKey: c.APIKey,
Libraries: c.Libraries,
WatchFolders: c.Folders(), // 正規化後的監看清單
// arcrun-rag#46:待撤清單也要一起遷過來。漏掉這裡=舊制使用者的
// retiring_folders 停在頂層、而 makeAccountSubConfig 會用帳號層覆蓋掉它
// ⇒ 撤除永遠不會發生,而且**沒有任何錯誤訊息**(同 t149 的形狀)。
RetiringFolders: c.RetiringRoots(),
}}
}
@@ -347,6 +364,30 @@ func (c *DirectConfig) Folders() []string {
return out
}
// RetiringRoots 回傳「已移除、雲端撤除進行中」的根清單(arcrun-rag#46)。
// 正規化方式與 Folders() 一致(頂層舊制+Accounts 新制、去重、保序)——刻意照抄
// 而不是只讀 Accounts:t149 的病就是「只讀了其中一層」,一整個新制設定被靜默忽略。
func (c *DirectConfig) RetiringRoots() []string {
seen := map[string]bool{}
var out []string
add := func(p string) {
if p == "" || seen[p] {
return
}
seen[p] = true
out = append(out, p)
}
for _, p := range c.RetiringFolders {
add(expandHome(p))
}
for _, a := range c.Accounts {
for _, p := range a.RetiringFolders {
add(expandHome(p))
}
}
return out
}
// 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 {
@@ -466,6 +507,9 @@ func (c *DirectConfig) makeAccountSubConfig(acc AccountConfig) *DirectConfig {
sub.InstanceName = acc.InstanceName
sub.WatchFolder = ""
sub.WatchFolders = acc.WatchFolders
// arcrun-rag#46:撤除中的資料夾同樣逐帳號隔離——撤除要打的是**這個帳號**的雲端實例,
// 沿用頂層清單會把 A 帳號的待撤資料夾拿去 B 帳號打(同 WatchFolders 的道理)。
sub.RetiringFolders = acc.RetiringFolders
sub.Libraries = acc.Libraries
if sub.Library == "" {
sub.Library = "kb"
@@ -575,6 +619,9 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
APIKey: cfg.APIKey,
Libraries: cfg.Libraries,
WatchFolders: cfg.Folders(),
// arcrun-rag#46:舊制(單帳號、頂層欄位)的待撤資料夾也要帶進來,
// 否則只有新制 accounts[] 的人撤得掉——t149 那個「只讀了其中一層」的病。
RetiringFolders: cfg.RetiringRoots(),
}}
}
@@ -589,6 +636,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
var totalProgress SyncProgress
var stuckReasons []string
// arcrun-rag#46:這一輪各個「移除並收回中」資料夾的進度(key=資料夾路徑)。
// 每輪重建、照現況重報(level-triggered),App 看到 done 才把設定裡那一筆清掉。
var retiring map[string]RetiringStatus
// t215:全域「雲端最新版」只抓一次(自帶節流,見 cloud_latest.go)——
// 這是所有帳號共用的同一把尺,不是逐帳號各打一次。
latestRelease, latestOK := FetchLatestCloudRelease()
@@ -684,6 +735,30 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
}
}
// arcrun-rag#46:把「使用者按了移除並收回」的資料夾撤乾淨。
// 放在看守資料夾之後:正在用的資料夾優先,收回是善後。
for _, root := range accCfg.RetiringRoots() {
r, e, remaining, done := retireRootOnce(accCfg, root, dryRun)
for i := range r {
r[i].Root = root
r[i].Account = accHost
}
results = append(results, r...)
if e != 0 {
exit = e
}
rs := RetiringStatus{Remaining: remaining, Done: done}
for _, x := range r {
if x.Status == "failed" && x.Error != "" {
rs.LastError = shortError(x.Error) // 最後一筆失敗的真因——不然畫面只會說「還在收回」
}
}
if retiring == nil {
retiring = map[string]RetiringStatus{}
}
retiring[root] = rs
}
// 2026-08-07:把這輪(可能剛更新過的)額度冷卻/今天已萃份數寫回,
// 供下一輪 RunDirectOnce(甚至下一次程序啟動——status.json 落地磁碟)復原。
accSt.DailyIngestedDate = todayUTC(now)
@@ -710,6 +785,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
ExtractorOK: extractorOK,
ExtractorError: extractorError,
AccountDetails: accountDetails,
Retiring: retiring, // arcrun-rag#46:移除並收回中的資料夾進度
}
// G-6.2:把「讀不了的檔」寫進狀態檔,App 首頁才有東西可以講。
// 排序=畫面每輪穩定(map 迭代順序隨機,不排的話清單會自己跳動)。
@@ -878,6 +954,156 @@ func saveDirectConfig(configPath string, cfg *DirectConfig) error {
return os.WriteFile(configPath, data, 0o600)
}
// drainPendingTakedowns 把 manifest 的「待下架」清單逐筆送去雲端撤除,成功一筆清一筆。
//
// 為什麼抽成共用函式(arcrun-rag#46):撤除的能力本來就存在且驗過(改名/搬移的舊路徑、
// 被監看資料夾裡被刪掉的檔都走它),缺的只是**「整個資料夾被移除」這條路沒有呼叫它**。
// 修法是把既有那條路叫起來,不是在別的地方再寫一份撤除邏輯——同一件事有兩份實作,
// 就會像 2026-07-24 的 source_uri 鍵那樣各自漂移,而且只有真機 e2e 才抓得到。
//
// 🔴 payload 帶 libraryarcrun-rag#46 邊界):撤除的比對鍵是 (page_name, path),而 path 是
// **相對於根**的路徑。兩個被監看的資料夾各自有 `notes.md` 時,兩邊的 (page_name, path) 完全
// 相同 ⇒ 撤除其中一個會連坐另一個。library 是逐根導出的(libraryFor),把它一起送上去,
// 雲端才有辦法只殺對的那一份。這與 ingest 送的 library 是**同一個函式**算出來的,
// 守 2026-07-24 那條教訓:成對操作(上架/下架)要用同一把鍵。
func drainPendingTakedowns(
cfg *DirectConfig, m *Manifest, absRoot, resultType, failPrefix string,
pace func(), dryRun bool, saveManifest func(),
) ([]DirectResult, int) {
var results []DirectResult
exit := 0
if len(m.PendingTakedowns) == 0 {
return results, exit
}
if dryRun {
for oldPath := range m.PendingTakedowns {
results = append(results, DirectResult{Type: resultType, Path: oldPath, Status: "planned"})
}
return results, exit
}
for oldPath, pageName := range m.PendingTakedowns {
pace()
res := DirectResult{Type: resultType, Path: oldPath}
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.RemovedWF), map[string]any{
"page_name": pageName,
"path": oldPath,
"library": cfg.libraryFor(absRoot),
})
res.HTTPStatus = status
if perr != nil {
res.Status, res.Error = "failed", failPrefix+perr.Error()
exit = 1
} else {
res.Status = "removed"
m.ClearTakedown(oldPath)
// 同步清掉本地舊卡(若還存在)——與「removed」分支同一套清理。
if cfg.Extractor != "" {
cardAbs := filepath.Join(absRoot, filepath.FromSlash(cardRelFor(absRoot, pageName)))
if _, serr := os.Stat(cardAbs); serr == nil {
_ = os.Remove(cardAbs)
}
if werr := RemoveWikiDoc(absRoot, oldPath); werr != nil {
results = append(results, DirectResult{
Type: "warning", Path: oldPath, Status: "skipped",
Error: "wiki 卡收走失敗(不擋下架):" + werr.Error(),
})
}
}
}
saveManifest()
results = append(results, res)
}
return results, exit
}
// retireRootOnce 對「使用者已經按下移除並收回、但雲端還沒撤乾淨」的資料夾跑一輪撤除
// arcrun-rag#46)。回傳結果、exit 建議、還剩幾筆沒撤成功、以及這個根是不是已經收乾淨。
//
// 為什麼不在 App 按下按鈕的當下同步做完:
// - 一個資料夾可能有上萬筆(實據 27,164 檔),同步做=畫面凍住;
// - 雲端剛好掛掉/額度用完時,一次性的動作會**永久遺失**這些待辦——
// 這正是 PendingTakedowns 當初存在的理由(見 manifest.go 該欄位註解)。
//
// ⇒ App 只負責把資料夾搬進 `retiring_folders`(設定檔,App 是唯一寫入者),
// collector 每輪把它排進 manifest 的待辦清單、照既有那條撤除路慢慢送、失敗自動下輪重試。
// 收乾淨了就刪掉該根的 manifest 檔,並在 status.json 把這個根標成 done——
// **level-triggered**(每輪照現況重報,不是只報一次的事件),App 漏看一輪也不會卡住。
func retireRootOnce(cfg *DirectConfig, root string, dryRun bool) (
results []DirectResult, exit int, remaining int, done bool,
) {
absRoot, err := filepath.Abs(expandHome(root))
if err != nil {
return []DirectResult{{Type: "folder_takedown", Path: root, Status: "failed",
Error: "解析資料夾路徑失敗:" + err.Error()}}, 1, 0, false
}
absManifest, err := filepath.Abs(cfg.manifestPathFor(absRoot))
if err != nil {
return []DirectResult{{Type: "folder_takedown", Path: root, Status: "failed",
Error: "解析帳本路徑失敗:" + err.Error()}}, 1, 0, false
}
cfg.migrateManifestIfNeeded(absRoot, absManifest)
// 沒有帳本=這個根從來沒同步過(或已經收乾淨了)⇒ 雲端沒有它的東西,直接算完成。
if _, serr := os.Stat(absManifest); errors.Is(serr, os.ErrNotExist) {
return nil, 0, 0, true
}
m, err := LoadManifest(absManifest, absRoot)
if err != nil {
return []DirectResult{{Type: "folder_takedown", Path: root, Status: "failed",
Error: "讀不了這個資料夾的帳本,暫不撤除(下輪重試):" + err.Error()}}, 1, 0, false
}
// 第一輪:把帳本裡「真的送上去過」的檔案排進待辦,然後清空 entries。
//
// 🔴 只排 IngestedHash 非空的:沒成功送上去過的檔案,雲端根本沒有它——為它送一次
// 撤除是零命中的空打。一個兩萬檔的資料夾裡若只有一百檔真的上去過,差別是 200 倍的
// 雲端呼叫(而且會跟正常同步搶同一個節流器)。
//
// 清空 entries 讓這一步天然冪等:下一輪回來時 entries 已空、只剩沒送成功的待辦,
// 不會把已經撤掉的又排一次。
if len(m.Entries) > 0 && !dryRun {
for path, e := range m.Entries {
if e != nil && strings.TrimSpace(e.IngestedHash) != "" {
m.QueueTakedown(path, pageNameOf(path))
}
}
m.Entries = map[string]*ManifestEntry{}
if serr := m.Save(absManifest); serr != nil {
return []DirectResult{{Type: "folder_takedown", Path: root, Status: "failed",
Error: "撤除待辦存檔失敗(下輪重試):" + serr.Error()}}, 1, len(m.PendingTakedowns), false
}
} else if dryRun {
for path, e := range m.Entries {
if e != nil && strings.TrimSpace(e.IngestedHash) != "" {
m.QueueTakedown(path, pageNameOf(path))
}
}
}
saveManifest := func() {
if dryRun {
return
}
if serr := m.Save(absManifest); serr != nil {
results = append(results, DirectResult{Type: "folder_takedown", Path: root,
Status: "failed", Error: "撤除待辦存檔失敗:" + serr.Error()})
}
}
dr, de := drainPendingTakedowns(cfg, m, absRoot, "folder_takedown",
"移除資料夾後的雲端撤除失敗(下輪重試):", pace, dryRun, saveManifest)
results = append(results, dr...)
exit = de
remaining = len(m.PendingTakedowns)
if remaining == 0 && !dryRun {
// 全部撤乾淨 ⇒ 帳本沒有存在的理由了。刪不掉不算失敗(下輪再刪;帳本已空,
// 就算留著也只是個空檔,不會讓資料復活)。
_ = os.Remove(absManifest)
done = true
}
return results, exit, remaining, done
}
// runDirectOnceRoot 對單一根掃一輪、直送 added/modified/renamed、下架 removed2xx 後回寫該根 manifest。
// retrySkipReason 產生「為什麼這輪跳過」的人話(t195)。
// 靜默跳過會讓使用者以為檔案被忽略了——狀態要說得出理由(t195 燈號誠實原則同源)。
@@ -1340,44 +1566,11 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
// InkStoneCo#44 ⑩:補打「改名/搬移後還沒下架成功」的舊路徑——包含本輪剛
// 上面排進去的,以及之前輪次失敗留下的(同一個待辦清單,一次處理完)。
// 與 orderedEvents 共用同一個節流器(pace),避免一輪多筆改名瞬間打爆雲端。
if len(m.PendingTakedowns) == 0 {
// no-op:沒有待辦
} else if dryRun {
for oldPath := range m.PendingTakedowns {
results = append(results, DirectResult{Type: "renamed_takedown", Path: oldPath, Status: "planned"})
}
} else {
for oldPath, pageName := range m.PendingTakedowns {
pace()
res := DirectResult{Type: "renamed_takedown", Path: oldPath}
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.RemovedWF), map[string]any{
"page_name": pageName,
"path": oldPath,
})
res.HTTPStatus = status
if perr != nil {
res.Status, res.Error = "failed", "改名/搬移後舊頁下架失敗(下輪重試):"+perr.Error()
exit = 1
} else {
res.Status = "removed"
m.ClearTakedown(oldPath)
// 同步清掉本地舊卡(若還存在)——與「removed」分支同一套清理,見下方。
if cfg.Extractor != "" {
cardAbs := filepath.Join(absRoot, filepath.FromSlash(cardRelFor(absRoot, pageName)))
if _, serr := os.Stat(cardAbs); serr == nil {
_ = os.Remove(cardAbs)
}
if werr := RemoveWikiDoc(absRoot, oldPath); werr != nil {
results = append(results, DirectResult{
Type: "warning", Path: oldPath, Status: "skipped",
Error: "wiki 卡收走失敗(不擋下架):" + werr.Error(),
})
}
}
}
saveManifest()
results = append(results, res)
}
dr, de := drainPendingTakedowns(cfg, m, absRoot, "renamed_takedown",
"改名/搬移後舊頁下架失敗(下輪重試):", pace, dryRun, saveManifest)
results = append(results, dr...)
if de != 0 {
exit = de
}
// 防呆警告輪:Scan 已壓下 removed 事件,這裡只回報警告不下架。