sync: collector/ 同步自 inkstone/arcrun-rag@c6cf717(桌面小幫手 0.18.38)

This commit is contained in:
2026-08-26 21:04:03 +08:00
parent a1786135d5
commit 4d966b7922
16 changed files with 1545 additions and 22 deletions
+78 -2
View File
@@ -475,6 +475,35 @@ func (c *DirectConfig) triggerURL(workflow string) string {
return fmt.Sprintf("%s/webhooks/named/%s/%s/trigger", c.CypherURL, c.Namespace, workflow)
}
// folderTreeURL 組出「回報資料夾樹」的端點(InkStoneCo#44 線 A)。
//
// 🔴 為什麼不是 named-webhook(也就是不做成一條 workflow):這件事是**登記**
// 不是知識萃取——它寫的是 portal 的登記簿(庫目錄+資料夾節點),與
// `/portal/daemon/extract`extract_workersai.go:86)、`/portal/daemon/libraries`
// 同一族、同一把 `X-Arcrun-API-Key`。做成 workflow 會為了「把結構化資料寫進登記簿」
// 在 workflow 裡塞一串 code 節點,那正是「表面走 Arcrun、實際整段寫 JS」的腹語術。
func (c *DirectConfig) folderTreeURL() string {
return c.CypherURL + "/portal/daemon/folder-tree"
}
// countsAsDocument 回答「這筆結果算不算**使用者的一份文件**」——
// 也就是該不該進 status.json 的 `extracted_ok` / `extract_failed`(=托盤上那句
// 「已整理 N 份 / ⚠ N 份失敗」)。
//
// 🔴 `folder_tree` 不算。它送的是**畫面用的資料夾結構**,不是知識、不是使用者的檔案:
// 收端只寫一把 KV,一份文件都沒有經手。
//
// 📌 這是本分支自己踩到的(InkStoneCo#44):漏掉這道閘的症狀是**空資料夾**——
// 一個檔都沒有,樹卻照樣要送(那正是 arcrun-rag#106 要的),一旦雲端連不上,
// 使用者的托盤就會顯示「⚠ 1 份失敗」而他根本沒有半份檔案。
// 「講一個假的 1」跟「安靜地少講」對使用者是同一件事(同 #104 那條紅線)。
//
// `inventory`(資料夾總覽卡)刻意**仍然算**:它是真的被寫進知識庫的一張卡,
// 使用者在雲端查得到它——與這裡的樹不是同一種東西。
func countsAsDocument(r DirectResult) bool {
return r.Type != "folder_tree"
}
// postJSON POST 一個 JSON body 到 url,回傳 HTTP 狀態碼與回應片段。非 2xx 視為錯誤。
func (c *DirectConfig) postJSON(url string, body any) (int, string, error) {
data, err := json.Marshal(body)
@@ -664,6 +693,12 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
// key=資料夾路徑(同一個根被多帳號看守時後寫覆蓋——策略只看資料夾,與帳號無關)。
folderPlans := map[string]FolderPlanStatus{}
// `inkstone/InkStoneCo#44`(桌面小幫手那半):每個看守資料夾這一輪的樹。
// 與 folderPlans 同一套 key 與同一套覆蓋語意。knownRoots 記「這一輪打算處理哪些根」
// ——合併時要靠它把已經不看守的根從快照裡刪掉(見 MergeFolderTreeStore ③)。
folderTrees := map[string]FolderTree{}
var knownRoots []string
// t210:跨帳號、跨資料夾累加的總量進度(見 rootProgress 註解)。
var totalProgress SyncProgress
var stuckReasons []string
@@ -737,6 +772,12 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
r, e, p, rp := runDirectOnceRoot(accCfg, root, dryRun, qs, now)
totalProgress = totalProgress.Add(rp.Progress)
stuckReasons = append(stuckReasons, rp.StuckReasons...)
// #44:這一根的樹。**掃壞了(Nodes 空)就不要覆蓋上一輪的好資料**——
// 合併時沿用舊的(MergeFolderTreeStore ②),畫面不會突然變成「還沒回報」。
knownRoots = append(knownRoots, root)
if len(rp.Tree.Nodes) > 0 {
folderTrees[root] = rp.Tree
}
// #140:有話要說才佔畫面(零值=這個資料夾沒有補送中的事)。
if rp.Resync.Pending > 0 || rp.Resync.Repaired > 0 || rp.Resync.LastError != "" {
if resync == nil {
@@ -751,7 +792,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
}
for i := range r {
r[i].Account = accHost // t104: 標明所屬帳號
if cfg.Extractor != "" {
if cfg.Extractor != "" && countsAsDocument(r[i]) {
switch r[i].Status {
case "ingested":
accSt.ExtractedOK++
@@ -894,6 +935,9 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
// 頂層彙總(向後相容:單帳號時填頂層欄位讓舊版 tray 仍能讀)
if cfg.Extractor != "" {
for _, r := range results {
if !countsAsDocument(r) {
continue
}
switch r.Status {
case "ingested":
st.ExtractedOK++
@@ -961,6 +1005,17 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
if serr := SaveSyncStatus(statusPath, st); serr != nil {
fmt.Fprintf(os.Stderr, "status 寫入失敗(不擋看守):%v\n", serr)
}
// `inkstone/InkStoneCo#44`:本機那份樹快照。
// 🔴 **刻意不放進 status.json**:桌面小幫手每秒讀一次 status.jsontick),
// 而一棵樹上限 300 個節點、一台機器可能看守好幾個資料夾
// ⇒ 併進去等於讓每一秒都去解析幾百 KB 只有「使用者按開樹」那一刻才要用的資料。
// 分成兩個檔,讀的人各取所需(小幫手只在展開時才讀這一份)。
treePath := FolderTreeStorePath(cfg.Manifest)
prevTrees, _ := LoadFolderTreeStore(treePath) // 讀不到=沒有上一輪,零值可用
if terr := SaveFolderTreeStore(treePath, MergeFolderTreeStore(prevTrees, folderTrees, knownRoots, now)); terr != nil {
fmt.Fprintf(os.Stderr, "資料夾結構寫入失敗(不擋看守):%v\n", terr)
}
}
return results, exit, lastPayload
@@ -1220,6 +1275,10 @@ type rootProgress struct {
// ——後者在沒事做的那輪會歸零,那正是 2026-08-05 leo 實撞的「明明做完了畫面卻寫等待中」。
// 沒事=零值,呼叫端不寫進 status.json(不製造常駐噪音)。
Resync ResyncStatus
// Tree=這一根這一輪算出來的資料夾樹(`inkstone/InkStoneCo#44`,桌面小幫手那半)。
// 🔴 **就是送上雲端的那一棵**BuildFolderTree 的產物原件),不是為了畫面另算一份。
// 呼叫端把它落地成 folder-trees.json,小幫手離線也攤得開(理由見 foldertree.go 檔尾)。
Tree FolderTree
}
// qs:這個帳號本輪共用的額度冷卻狀態(跨同帳號的多個監看根,見 quota.go)。
@@ -1380,6 +1439,23 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
}
}
// InkStoneCo#44 線 Aleo 2026-08-17):把「這個資料夾長什麼樣、每一層同步了幾份/
// 總共幾份」送上 portal。與上面的總覽卡刻意分開——那張是給檢索用的 markdown 卡,
// 這棵是給畫面用的結構化資料,消費者不同(理由全文見 foldertree.go 檔頭)。
//
// 位置與總覽卡同一段的三個理由完全通用(不等 LLM/在額度閘之外/反映現況),
// 再加第四個**只屬於它**的:**空資料夾一個事件都不會有**arcrun-rag#106 的情境本身),
// 所以它不能被任何「有事件才做」的閘擋住——分子分母都由現況算出,靜止時
// 內容雜湊自然擋住重送,不需要事件當第二道閘。
tree := BuildFolderTree(absRoot, cfg.libraryFor(absRoot), payload.DirStats, m.Entries,
payload.AllExcludedDirs, plan, runNow)
if treeRes := syncFolderTree(cfg, absRoot, m, tree, dryRun, runNow); treeRes != nil {
results = append(results, *treeRes)
if treeRes.Status != "planned" {
saveManifest() // 記住 folder_tree_hash/失敗退避
}
}
// 承上:把本輪偵測到的 removed 路徑暫時放回 m.Entries,直到迴圈裡真的處理到它、
// POST 成功才由「removed」分支明確刪除。失敗或本輪還沒輪到(單輪上限)都維持放回的狀態,
// 下一輪自然重新偵測、重新嘗試下架——不會因為別的事件先存檔而被誤永久跳過。
@@ -1721,7 +1797,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
// t210manifest 走到這裡已經是本輪最終狀態(每個事件處理完就地更新),
// 原地數一次就是對的(同 SkippedDocCount 那套「現況快照」邏輯,不必另外維護計數器)。
rp := rootProgress{Progress: m.Progress()}
rp := rootProgress{Progress: m.Progress(), Tree: tree}
for _, e := range m.Entries {
if e != nil && e.FailCount >= MaxFailBeforeSkip {
// LastError 原文交給呼叫端彙總後過 ClassifyFailure——分類判斷只住那一個接縫,