sync: collector/ 同步自 inkstone/arcrun-rag@45b74a4(桌面小幫手 0.18.36)

This commit is contained in:
2026-08-24 18:26:55 +08:00
parent f1658fd2ad
commit d028a66625
19 changed files with 3278 additions and 29 deletions
+543
View File
@@ -0,0 +1,543 @@
// cleanup.go — 斷連時把 daemon 自己寫進使用者資料夾的東西收回來
// arcrun-rag#138leo 2026-08-24:「碎型會在每個資料夾安裝隱藏資料夾,人工刪除不容易,
// 所以當它斷連,應該要可以幫它把 Arcrun RAG 建立的資料夾刪掉」)。
//
// 🔴 這是本 repo 唯一一支會**刪使用者資料夾裡的檔**的程式碼。tidy.go 的紅線是
// 「一行 os.Remove 都沒有」——那條紅線在這裡不成立(使用者要的就是刪掉),
// 所以改用另一組更嚴的規矩:
//
// ① **只刪帳面上認得出來的東西。** 不靠「路徑看起來像我們的」「副檔名是 .md」
// 這類推測——每一筆都要說得出一句**我們自己寫下的證據**(見 CleanupItem.Evidence)。
// ② **一個認不出來的鄰居,就讓整個目錄留下。** `.wiki/` 裡只要有一個檔不在帳本上、
// 也沒有標記,就從「刪整個目錄」降級成「只刪認得出來的那幾個檔」,目錄本身留著。
// ⇒ 誤留一點殘渣是可接受的;誤刪使用者的知識不是。
// ③ **先看清單再動手。** PlanCleanup 只讀不寫,ApplyCleanup 動手前**重算一次**計畫
// (不吃呼叫端傳回來的舊清單)——畫面上看到的是快照,刪的當下才是真相。
//
// 認人的三種證據(全部是 daemon 自己寫下去的字,不是猜的):
//
// E1 `.arcrun-rag/` 工作區:目錄裡的 `.gitignore` 內容**逐字**等於 workspaceIgnoreBody
// repoguard.go 寫的那份,自己寫著「整個資料夾可以安全刪除」)。
// E2 `.wiki/` 卡片目錄:目錄裡的 `.gitignore` 內容逐字等於 wikiIgnoreBody
// wikishape.go 寫的,自己寫著「不是你的檔案」),或目錄裡有解析得開的
// `manifest.json`(我們的 wiki 帳本)。
// E3 `system-dev/wiki/cards/` 底下帶 MachineMark`arcrun-`)前綴的檔
// ——machinemark.go 的規約:daemon 在監看根底下產生的每個檔名都帶這個前綴。
//
// 🔴 三個**刻意不刪**的東西(每一個都是實查出來的,不是保守而已):
//
// ① `.arcrun-rag/legacy-template/`:那是 tidy.go 的**收容處**,裡面裝的是從使用者
// 資料夾**搬進來的檔**。tidy 自己說得很清楚:「TemplateOwns 是路徑身分判準,
// 分不出『daemon 鋪的』還是『repo 本來就有的』」⇒ 那些檔的所有權我們判不了。
// 而 `.arcrun-rag/.gitignore` 上「整個資料夾可以安全刪除」那句話是 #105 寫的,
// 當時工作區裡還沒有收容處。**不把那句話套到收容處上**(#138 紅線第三條問的正是這題)。
// ② 另一個**還在監看清單裡**的資料夾底下的東西:實務上真的會巢狀
// 2026-08-24 現場:`pms` 與 `pms/pms_v1_legacy` 同時在看守)。移除外層時把內層的
// 工作區刪掉=把一個還在跑的同步弄壞。
// ③ 任何 symlink:不跟隨、不刪。刪一條指出去的連結會刪到監看根以外的東西。
package collector
import (
"encoding/json"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// 產物分類(給畫面分組用,也是 Apply 的處理方式)。
const (
CleanupKindWorkspace = "workspace" // `.arcrun-rag/` 工作區(整個目錄)
CleanupKindWorkFile = "work-file" // 工作區裡的單一項目(收容處存在時的降級路徑)
CleanupKindWikiDir = "wiki-dir" // `.wiki/` 卡片目錄(整個目錄)
CleanupKindWikiFile = "wiki-file" // `.wiki/` 裡的單一產物(有陌生鄰居時的降級路徑)
CleanupKindMarked = "marked-card" // 帶 arcrun- 標記的卡片(非隱藏的卡片產物區)
)
// CleanupItem=一筆「打算刪掉」的東西。Evidence 是白話的認人依據,會直接顯示給使用者看
// ——沒有 Evidence 的東西不准進這個清單。
type CleanupItem struct {
Rel string `json:"rel"` // 相對監看根,斜線分隔
IsDir bool `json:"is_dir"`
Kind string `json:"kind"`
Evidence string `json:"evidence"`
Files int `json:"files"` // 這一筆底下有幾個檔(目錄才 > 1)
Bytes int64 `json:"bytes"`
}
// CleanupKeep=一筆「認得出來但刻意不刪」或「認不出來所以不敢刪」的東西。
// 這份清單和 Remove 一樣要給使用者看——沉默地留下殘渣,跟沉默地刪掉一樣糟。
type CleanupKeep struct {
Rel string `json:"rel"`
Reason string `json:"reason"`
}
// CleanupPlan=一次清理的完整帳目。PlanCleanup 只讀不寫,這份東西就是「動手前的清單」。
type CleanupPlan struct {
Root string `json:"root"`
Remove []CleanupItem `json:"remove"`
Keep []CleanupKeep `json:"keep"`
Files int `json:"files"` // Remove 涵蓋的檔案總數
Bytes int64 `json:"bytes"`
}
// CleanupFailure=刪不掉的那一筆(權限、檔案正被開著…)。
type CleanupFailure struct {
Rel string `json:"rel"`
Error string `json:"error"`
}
// CleanupResult=實際刪掉了什麼。
type CleanupResult struct {
Removed []string `json:"removed"`
Failed []CleanupFailure `json:"failed"`
Files int `json:"files"`
Bytes int64 `json:"bytes"`
}
var cleanupBakRe = regexp.MustCompile(`^(.*)\.bak-\d+$`)
// WorkspaceIgnoreBodyForTestWikiIgnoreBodyForTest 讓**別的 package 的迴歸網**
// cmd/arcrun-app)造得出「與 daemon 真正寫下去一字不差」的宣告檔。
//
// 為什麼不讓測試自己抄一份字串:抄的那份會漂。認人的判準是「逐字相等」,
// 判準與樣本一旦不同源,這兩份 .gitignore 改一個字就會有一整層測試靜默失效。
// 名字帶 ForTest 是說給讀 code 的人聽的——**production 路徑不准依賴它們**。
func WorkspaceIgnoreBodyForTest() string { return workspaceIgnoreBody }
func WikiIgnoreBodyForTest() string { return wikiIgnoreBody }
// PlanCleanup 列出「把 root 從監看清單移除時,可以連帶收掉的 daemon 產物」。
//
// **這支函式不寫任何檔案。** otherRoots=其他仍在監看清單裡的資料夾(絕對路徑);
// 落在它們底下的東西一律不碰(見檔頭不刪清單②)。
func PlanCleanup(root string, otherRoots []string) (*CleanupPlan, error) {
absRoot, err := filepath.Abs(root)
if err != nil {
return nil, err
}
absRoot = filepath.Clean(absRoot)
if st, serr := os.Lstat(absRoot); serr != nil {
return nil, serr
} else if !st.IsDir() {
return nil, &fs.PathError{Op: "cleanup", Path: absRoot, Err: fs.ErrInvalid}
}
// 只留「真的在 absRoot 底下」的其他根——不在底下的本來就走不到,留著只會拖慢比對。
var others []string
for _, o := range otherRoots {
a, oerr := filepath.Abs(o)
if oerr != nil {
continue
}
a = filepath.Clean(a)
if a != absRoot && pathWithin(absRoot, a) {
others = append(others, a)
}
}
p := &cleanupPlanner{
root: absRoot,
others: others,
cards: collectWikiCardIndex(absRoot, others),
plan: &CleanupPlan{Root: absRoot},
}
p.walk(absRoot)
sort.Slice(p.plan.Remove, func(i, j int) bool { return p.plan.Remove[i].Rel < p.plan.Remove[j].Rel })
sort.Slice(p.plan.Keep, func(i, j int) bool { return p.plan.Keep[i].Rel < p.plan.Keep[j].Rel })
return p.plan, nil
}
// ApplyCleanup 真的動手刪。
//
// 🔴 它**重算一次計畫**,不接受呼叫端傳進來的舊清單——使用者看清單、按確定之間可能過了
// 幾秒或幾分鐘,那段時間裡他可能剛好把一個自己的檔存進 `.wiki/`。畫面上那份是快照,
// 刪的當下重算的這份才是判準。回傳的 plan 就是實際執行的那一份(呼叫端要顯示就用它)。
func ApplyCleanup(root string, otherRoots []string) (*CleanupPlan, *CleanupResult, error) {
plan, err := PlanCleanup(root, otherRoots)
if err != nil {
return nil, nil, err
}
res := &CleanupResult{}
// 由深到淺刪:先刪目錄裡的東西,再刪目錄自己(降級路徑會同時出現父目錄底下的多筆)。
items := append([]CleanupItem(nil), plan.Remove...)
sort.Slice(items, func(i, j int) bool { return len(items[i].Rel) > len(items[j].Rel) })
for _, it := range items {
abs := filepath.Join(plan.Root, filepath.FromSlash(it.Rel))
// 最後一道機械閘:不管計畫怎麼算的,目標一定要在監看根底下,而且不是 symlink。
if !pathWithin(plan.Root, abs) {
res.Failed = append(res.Failed, CleanupFailure{Rel: it.Rel, Error: "路徑不在這個資料夾底下,沒有刪"})
continue
}
st, serr := os.Lstat(abs)
if serr != nil {
if os.IsNotExist(serr) {
continue // 已經不在了(重複按、或上一輪刪過)=不是失敗
}
res.Failed = append(res.Failed, CleanupFailure{Rel: it.Rel, Error: serr.Error()})
continue
}
if st.Mode()&os.ModeSymlink != 0 {
res.Failed = append(res.Failed, CleanupFailure{Rel: it.Rel, Error: "這是一條捷徑(symlink),沒有刪"})
continue
}
var derr error
if it.IsDir {
derr = os.RemoveAll(abs)
} else {
derr = os.Remove(abs)
}
if derr != nil {
res.Failed = append(res.Failed, CleanupFailure{Rel: it.Rel, Error: derr.Error()})
continue
}
res.Removed = append(res.Removed, it.Rel)
res.Files += it.Files
res.Bytes += it.Bytes
}
sort.Strings(res.Removed)
return plan, res, nil
}
// ── 以下是實作細節 ────────────────────────────────────────────────
type cleanupPlanner struct {
root string
others []string
cards map[string]bool // 帳本上每張卡的絕對路徑
plan *CleanupPlan
}
func (p *cleanupPlanner) rel(abs string) string {
r, err := filepath.Rel(p.root, abs)
if err != nil {
return filepath.ToSlash(abs)
}
return filepath.ToSlash(r)
}
func (p *cleanupPlanner) remove(abs, kind, evidence string, isDir bool) {
files, bytes := countTree(abs)
p.plan.Remove = append(p.plan.Remove, CleanupItem{
Rel: p.rel(abs), IsDir: isDir, Kind: kind, Evidence: evidence, Files: files, Bytes: bytes,
})
p.plan.Files += files
p.plan.Bytes += bytes
}
func (p *cleanupPlanner) keep(abs, reason string) {
p.plan.Keep = append(p.plan.Keep, CleanupKeep{Rel: p.rel(abs), Reason: reason})
}
// walk 走整棵樹。刻意**不用 filepath.WalkDir**:我們要在「進不進去某個目錄」這件事上
// 完全自己說了算(其他隱藏目錄整棵跳過、別的監看根整棵跳過、symlink 不跟隨)。
func (p *cleanupPlanner) walk(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
abs := filepath.Join(dir, e.Name())
if e.Type()&os.ModeSymlink != 0 {
continue // 捷徑一律不碰(可能指到監看根以外)
}
if !e.IsDir() {
continue // 目錄以外的檔,只有在下面那幾個已認定的目錄裡才會被處理
}
if p.underOtherRoot(abs) {
p.keep(abs, "這個資料夾也還在同步清單裡,它底下的東西留給它自己管")
continue
}
switch e.Name() {
case workspaceRelDir:
p.planWorkspace(abs)
continue
case wikiRelDir:
p.planWikiDir(abs)
continue
}
if strings.HasPrefix(e.Name(), ".") {
continue // 其他隱藏目錄整棵跳過——daemon 的 Scan 也不進去,那裡不會有我們的東西
}
if p.rel(abs) == cardsRelDir {
p.planMarkedCards(abs)
continue
}
p.walk(abs)
}
}
func (p *cleanupPlanner) underOtherRoot(abs string) bool {
for _, o := range p.others {
if abs == o || pathWithin(o, abs) {
return true
}
}
return false
}
// planWorkspace 處理 `.arcrun-rag/`。
func (p *cleanupPlanner) planWorkspace(abs string) {
if !fileHasExactBody(filepath.Join(abs, ".gitignore"), workspaceIgnoreBody) {
p.keep(abs, "這個 .arcrun-rag 目錄裡沒有我們寫的 .gitignore 宣告,認不出是不是我們建的,所以不動它")
return
}
const ev = "目錄裡的 .gitignore 逐字等於我們寫的那份(自己寫著「整個資料夾可以安全刪除」)"
quarantine := filepath.Join(abs, "legacy-template")
if st, err := os.Lstat(quarantine); err == nil && st.IsDir() && !isEmptyDir(quarantine) {
// 收容處裡是「從你的資料夾搬進來的檔」⇒ 整個目錄不能一次刪,改成逐項刪、留下收容處。
p.keep(quarantine, "這裡面是以前從你的資料夾搬進來的檔案(不是我們產生的),所有權判不了,一律留著讓你自己處理")
entries, err := os.ReadDir(abs)
if err != nil {
return
}
for _, e := range entries {
if e.Name() == "legacy-template" {
continue
}
child := filepath.Join(abs, e.Name())
if e.Type()&os.ModeSymlink != 0 {
p.keep(child, "這是一條捷徑(symlink),不碰")
continue
}
p.remove(child, CleanupKindWorkFile, ev, e.IsDir())
}
return
}
p.remove(abs, CleanupKindWorkspace, ev, true)
}
// planWikiDir 處理一個 `.wiki/`。
//
// 兩步:① 這個目錄是不是我們的(要有我們寫下的宣告)② 裡面**每一個**東西是不是都認得。
// 第二步是這支函式的重點——認得全部才刪整個目錄,有一個認不得就降級成逐檔刪。
func (p *cleanupPlanner) planWikiDir(abs string) {
evidence := ""
if fileHasExactBody(filepath.Join(abs, ".gitignore"), wikiIgnoreBody) {
evidence = "目錄裡的 .gitignore 逐字等於我們寫的那份(自己寫著「這個資料夾是 Arcrun RAG 產生的 wiki,不是你的檔案」)"
} else if isOurWikiManifest(filepath.Join(abs, "manifest.json")) {
evidence = "目錄裡有我們寫的 manifest.json(wiki 帳本,記著每張卡對應哪份原稿)"
} else {
p.keep(abs, "這個 .wiki 目錄裡既沒有我們寫的 .gitignore、也沒有我們的 manifest.json,認不出是不是我們建的,所以不動它")
return
}
entries, err := os.ReadDir(abs)
if err != nil {
p.keep(abs, "讀不到這個目錄的內容("+err.Error()+"),所以不動它")
return
}
type known struct {
name string
isDir bool
}
var ours []known
var strangers []string
accounted := map[string]bool{}
for _, e := range entries {
if p.wikiEntryIsOurs(abs, e) {
accounted[e.Name()] = true
}
}
for _, e := range entries {
if accounted[e.Name()] {
ours = append(ours, known{e.Name(), e.IsDir()})
continue
}
// `.bak-<unixnano>` 是 safeWriteCard 的備份:本體認得,它就認得。
if m := cleanupBakRe.FindStringSubmatch(e.Name()); m != nil && accounted[m[1]] {
ours = append(ours, known{e.Name(), e.IsDir()})
continue
}
strangers = append(strangers, e.Name())
}
if len(strangers) == 0 {
p.remove(abs, CleanupKindWikiDir, evidence, true)
return
}
// 降級:只刪認得出來的那幾個,目錄與陌生鄰居原封不動。
for _, k := range ours {
p.remove(filepath.Join(abs, k.name), CleanupKindWikiFile, evidence, k.isDir)
}
for _, s := range strangers {
p.keep(filepath.Join(abs, s), "這個檔不在我們的帳本上、也沒有 arcrun- 標記——可能是你自己放進來的,留著")
}
}
// wikiEntryIsOurs 回答「`.wiki/` 裡的這一項是不是我們寫的」。四條路,全部要拿得出證據。
func (p *cleanupPlanner) wikiEntryIsOurs(dir string, e os.DirEntry) bool {
if e.Type()&os.ModeSymlink != 0 {
return false // 捷徑一律不算我們的
}
abs := filepath.Join(dir, e.Name())
if e.IsDir() {
return false // 我們從來不在 .wiki/ 底下開子目錄
}
switch {
case e.Name() == ".gitignore":
return fileHasExactBody(abs, wikiIgnoreBody)
case e.Name() == "manifest.json":
return isOurWikiManifest(abs)
case e.Name() == "00-INDEX.md":
return fileHasPrefix(abs, "# 00-INDEX")
case p.cards[abs]:
return true // 帳本上有這張卡
case IsMarked(e.Name()):
return true // machinemark.go 的規約前綴
}
return false
}
// planMarkedCards 處理 `system-dev/wiki/cards/`——那是**看得見**的卡片產物區
// (監看根不在筆記庫也不在版控時的落點)。這裡不刪目錄,只刪帶標記的檔:
// 那個目錄是 system-dev-template 的規約路徑,使用者自己的卡也住在那裡(#105 的教訓)。
func (p *cleanupPlanner) planMarkedCards(abs string) {
_ = filepath.WalkDir(abs, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || d.Type()&os.ModeSymlink != 0 {
return nil
}
if IsMarked(d.Name()) {
p.remove(path, CleanupKindMarked, "檔名帶 arcrun- 標記——daemon 產生的每個檔都帶這個前綴(machinemark.go 規約)", false)
}
return nil
})
}
// collectWikiCardIndex 把樹裡每一份 `.wiki/manifest.json` 讀出來,攤成「這些絕對路徑是我們的卡」。
//
// 為什麼要掃整棵樹而不是只讀監看根那一份:實務上樹裡會有**別的監看根**留下的帳本
// `pms/pms_v1_legacy/.wiki/manifest.json`)。帳本裡的卡路徑是相對**它自己的根**
// (=那個 `.wiki` 的上層目錄),所以每份帳本各自解析。
func collectWikiCardIndex(absRoot string, others []string) map[string]bool {
out := map[string]bool{}
var walk func(dir string)
walk = func(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if !e.IsDir() || e.Type()&os.ModeSymlink != 0 {
continue
}
abs := filepath.Join(dir, e.Name())
if e.Name() == wikiRelDir {
addManifestCards(out, abs)
continue
}
if strings.HasPrefix(e.Name(), ".") {
continue
}
walk(abs)
}
}
walk(absRoot)
// 別的監看根底下那份帳本也要讀——它記的卡可能落在**我們這一側**(節點在共同祖先下)。
// 讀帳本是純讀取,不代表會刪那邊的東西(刪不刪由 underOtherRoot 決定)。
for _, o := range others {
addManifestCards(out, filepath.Join(o, wikiRelDir))
}
return out
}
func addManifestCards(out map[string]bool, wikiDir string) {
path := filepath.Join(wikiDir, "manifest.json")
m, ok := readOurWikiManifest(path)
if !ok {
return
}
owner := filepath.Dir(wikiDir) // 帳本裡的路徑相對這一層
for _, d := range m.Docs {
for _, c := range d.Cards {
c = strings.TrimSpace(c)
if c == "" || strings.Contains(c, "..") || filepath.IsAbs(filepath.FromSlash(c)) {
continue // 帳本被改壞時不讓它把我們指到樹外面
}
out[filepath.Join(owner, filepath.FromSlash(c))] = true
}
}
}
func readOurWikiManifest(path string) (*wikiManifest, bool) {
data, err := os.ReadFile(path)
if err != nil {
return nil, false
}
var m wikiManifest
if json.Unmarshal(data, &m) != nil {
return nil, false
}
// 認的是形狀,不是「檔名叫 manifest.json」:要有版本號,而且每筆 doc 都要有我們的鍵。
if m.Version <= 0 {
return nil, false
}
for _, d := range m.Docs {
if d.Node == "" || d.DocID == "" {
return nil, false
}
}
return &m, true
}
func isOurWikiManifest(path string) bool {
_, ok := readOurWikiManifest(path)
return ok
}
// fileHasExactBody:檔案內容**逐字**等於 want。逐字比對是刻意的——「開頭像」不夠,
// 有人可能在我們的宣告後面接自己的規則,那份 .gitignore 就不只是我們的了。
func fileHasExactBody(path, want string) bool {
data, err := os.ReadFile(path)
return err == nil && string(data) == want
}
func fileHasPrefix(path, want string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
buf := make([]byte, len(want))
n, _ := f.Read(buf)
return n == len(want) && string(buf) == want
}
// pathWithin 回答 child 是不是 parent 底下(不含 parent 自己)。純字串比對,
// 呼叫端傳進來的都已經是 Abs+Clean 過的路徑。
func pathWithin(parent, child string) bool {
rel, err := filepath.Rel(parent, child)
if err != nil {
return false
}
return rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)
}
func isEmptyDir(dir string) bool {
entries, err := os.ReadDir(dir)
return err == nil && len(entries) == 0
}
// countTree 算一筆刪除目標涵蓋幾個檔、多少位元組(給清單顯示「會刪掉 N 個檔」)。
func countTree(abs string) (files int, bytes int64) {
st, err := os.Lstat(abs)
if err != nil {
return 0, 0
}
if !st.IsDir() {
return 1, st.Size()
}
_ = filepath.WalkDir(abs, func(_ string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if info, ierr := d.Info(); ierr == nil {
files++
bytes += info.Size()
}
return nil
})
return files, bytes
}