Files
arcrun-collector/sync_status.go
Leo 97309b5580 fix(collector): 排除判準不看版控、不誤殺筆記庫、剪掉什麼講得出來(arcrun-rag#104)
票上寫的真兇是錯的。`direct.go` 那一行 `skipDirNames{"system-dev"}` 不是唯一的
排除清單——同一個 Scan 呼叫下面幾行就是 `Plan: plan`,#104 的清單一直都接著。
拿 leo 真實的 `pms` 唯讀跑一輪現行 main:策略 docs-only、送 9 個檔、node_modules 零個。
他 08-16 看到 undici 文件,是因為手上的 daemon 是 v0.18.27,而修法 cc6e500 要到
v0.18.28(08-16 16:13,7f379d0)才被戳版號——那支 commit 自己就寫著
「changelog 停在 v0.18.27,而 collector/ 早已往前走 30 個檔(…cc6e500…)」。

但那個誤判之所以會發生,是因為底下有四個真的缺陷,這一版把它們一起修掉:

① 兩張表分居兩處 ⇒ 讀源碼的人只看得到一張。
   `system-dev` 的保護搬進 IngestPlan(templateOwnedDirNames),
   direct.go 不再手捏第二張清單。判準只剩一個地方。

② 排除規則生不生效,取決於呼叫端記不記得傳 Plan。
   改成 Scan 自己算(Mode == "" ⇒ PlanIngest)。「忘了接」這個失敗模式不存在了。

③ 一張大表把「沒有人會這樣命名」與「這是普通英文字」混在一起,於是**誤殺**。
   實測:一般筆記庫 8 份筆記只送出 1 份(build/樂高作品集、out/外出旅遊、
   vendor/廠商聯絡簿…全被當成建置產物),而且回報「擋掉 0 個」。
   拆成三種理由,強度不同、要求的佐證也不同:
     ① 使用者的 .gitignore 說的(新增 ignorerules.go,git 語法的安全子集)
     ② 名字本身就不是人話(node_modules、__pycache__…)——無條件
     ③ 泛用名(build/dist/out/vendor…)——**旁邊真的擺著專案檔才算**
   🔴 判準一律不看 `.git`(leo 2026-08-16:「你不需要判斷有沒有 git,
   我的 KB 筆記庫也有 git,是否用 github/gitea 追蹤完全沒意義」)。
   `.gitignore` 只讀內容當線索,不拿存在當門檻。
   順帶:鎖定檔(pnpm-lock.yaml…)不是知識——`.yaml` 進白名單後它變成了「知識」。

④ 「排除規則要看得見」只做了一半:整棵剪掉的子樹一個都沒數(pms 實測回報 0),
   而且 Plan/ExcludedByPlan 只有 CLI 讀,daemon(使用者真正走的那條路)拿到就丟。
   新增 ExcludedDirs(路徑+人話理由)+ SyncStatus.FolderPlans 寫進 status.json。

實測(唯讀跑 leo 的 `/Users/youlinhsieh/Documents/tech_projects/pms`):
  139 個文件檔 → 送出 7 個,全是他自己的 README/docs;
  6 個資料夾整棵跳過,每個都講得出理由;node_modules 與授權條款 0 個。

測試:collector 全綠(新增 12 案,含「裸呼叫 Scan 也必須排除別人的套件」、
「不准再有第二張排除清單」的源碼層守門、筆記庫不誤殺、同名看旁邊擺什麼決定);
arcrun-app 全綠。未出貨、未推 main。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:48:22 +08:00

209 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// sync_status.go — 每輪同步後的彙總狀態(t91 狀態可見性)。
// 寫成 JSON 供托盤讀取,讓使用者第一眼看到萃取是否正常。
package collector
import (
"encoding/json"
"os"
"path/filepath"
)
// AccountSyncStatus 彙總單一帳號的每輪同步結果(t104 多帳號看守)。
// key in SyncStatus.AccountDetails = instanceHostOf(cypher_url)。
type AccountSyncStatus struct {
LastSync string `json:"last_sync,omitempty"`
CloudVersion string `json:"cloud_version,omitempty"` // t103 per-account
CloudCheckOK bool `json:"cloud_check_ok"`
// t2152026-08-08leo:「在每個知識庫上顯示是否要更新」):這個帳號的雲端知識庫
// 有沒有新版可以裝——與 portal 版本卡同一套判準(EvalCloudUpdatecloud_latest.go),
// **不是**上面 CloudVersion/CloudCheckOK 搭配 t103 cloudVersionStale 那把尺
// (那把量的是「太舊會不相容」的協定底線,這裡量的是「有沒有更新版可以裝」)。
CloudUpdateKnown bool `json:"cloud_update_known"`
CloudUpdateStale bool `json:"cloud_update_stale"`
CloudLatest string `json:"cloud_latest,omitempty"` // 已知的最新版(供畫面顯示「最新版 x.y.z」)
ExtractedOK int `json:"extracted_ok"`
ExtractFailed int `json:"extract_failed"`
// t182leo 08-04:「沒裝好就顯示 workers AI 還沒通,一旦通了就顯示可用」):
// 這個帳號的雲端實例有沒有 /portal/daemon/extract。**逐帳號**各自記——
// 用戶可能有多個實例、更新進度不同步。只在走 workers-ai 這條路時探測。
CloudAIReady bool `json:"cloud_ai_ready"`
CloudAINote string `json:"cloud_ai_note,omitempty"` // 還沒通時的白話說明(含該做什麼)
// ── 額度冷卻(2026-08-07 pacing task 2)───────────────────────────────────
// Workers AI 每日免費額度用完時,不能每輪繼續撞同一面牆——這裡記「冷卻到什麼時候」
// 與「今天已經做了幾份」,跨輪讀回(見 direct.go RunDirectOnce 開頭載入 prevStatus)。
DailyIngestedDate string `json:"daily_ingested_date,omitempty"` // YYYY-MM-DDUTC,與額度重置同一條日界線)
DailyIngestedCount int `json:"daily_ingested_count"` // 今天已成功萃取的份數
QuotaCooldownUntil string `json:"quota_cooldown_until,omitempty"` // RFC3339;非空且未到=本帳號本輪不再嘗試萃取
// QuotaMessage=額度冷卻中要給使用者看的三句話(見 quota.go QuotaNotice)。
// 冷卻結束且本輪沒有新命中 ⇒ 每輪重建的 AccountSyncStatus 不會再設它,自然清除。
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 {
LastSync string `json:"last_sync,omitempty"` // RFC3339,最近一輪完成時間
ExtractedOK int `json:"extracted_ok"` // 本輪萃取成功件數(跨帳號累計)
ExtractFailed int `json:"extract_failed"` // 本輪萃取失敗件數(跨帳號)
Failures []ExtractFail `json:"failures,omitempty"` // 失敗清單(路徑+白話原因)
ExtractorOK bool `json:"extractor_ok"` // 萃取器本身是否就緒(預檢,機器層級)
ExtractorError string `json:"extractor_error,omitempty"` // 未就緒的白話原因
// 🔴 最近一輪「真的有做事」的結果(2026-08-05,leo 實撞)。
// ExtractedOK/ExtractFailed 是**本輪**計數、每輪覆寫 ⇒ 沒事做的那輪就歸零。
// leo 拖檔進資料夾,萃取上傳都跑完了,但下一輪(15 秒後)把數字歸零
// ⇒ 首頁「上一輪 N 份」永遠空白,看起來像從頭到尾什麼都沒發生。
// ⇒ 另存一組「上次有產出的那輪」,沒事做的輪次原樣往下帶,不被清掉。
LastActivityAt string `json:"last_activity_at,omitempty"` // RFC3339,上次有產出那輪的完成時間
LastActivityOK int `json:"last_activity_ok"` // 那一輪成功幾份
LastActivityFailed int `json:"last_activity_failed"` // 那一輪失敗幾份
// 頂層 cloud 欄位保留向後相容(同時填頂層+AccountDetails;不論帳號數——
// arcrun-rag#59 相關實查修過,見 direct.go 寫入處註解:以前只有剛好一個帳號
// 才填,2+ 帳號時這裡恆為零值 false,讀這個頂層欄位的地方會看到「沒連上雲端」
// 即使每個帳號都連得上)。CloudCheckOK=任一帳號連得上;CloudVersion=第一個
// 非空版本代表,不代表所有帳號版本一致。
CloudVersion string `json:"cloud_version,omitempty"` // bundle_version(有連得上的帳號時填)
CloudCheckOK bool `json:"cloud_check_ok"` // 任一帳號 /health 可達即為 true
// 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 進資料夾,得到的回應是**完全的沉默**。
// ⇒ 每輪把它們帶出來,讓 App 首頁講一句人話。
//
// ⚠️ 與 ExtractedOK 不同,**這三個欄位不進 CarryForwardActivity**
// 它們是每輪重走檔案系統算出來的「現況快照」,不是「本輪做了幾件事」的計數
//(後者才會在沒事做的那輪被歸零=db17f28 修的那個病)。
// 檔案還躺在資料夾裡,每輪都會被重新數到,所以原地重算就是對的。
SkippedDocs []SkippedFile `json:"skipped_docs,omitempty"` // 逐檔點名(已排序,上限 MaxSkippedListed
SkippedDocCount int `json:"skipped_doc_count"` // 文件類被略過的**總數**(可能大於清單長度)
SkippedOtherCount int `json:"skipped_other_count"` // 其餘非文件檔(圖片/影音/程式碼…)總數
// 少量時附上檔名(上限 maxOtherNames)。只報總數在「1 個」時等於沒說——
// leo 08-06 封測者放了 .md 說「無法通過」,畫面只有「有 1 個不是文件的檔案」,
// 沒人判斷得出那到底是什麼檔。
SkippedOtherNames []string `json:"skipped_other_names,omitempty"`
// FolderPlans=每個看守資料夾這一輪的收檔策略與少收了什麼(key=資料夾路徑)。
// 與 SkippedDocs 同族:每輪照現況重算的快照,**不進 CarryForwardActivity**。
FolderPlans map[string]FolderPlanStatus `json:"folder_plans,omitempty"`
// ── t210 統計層(2026-08-08Evan 封測:「9000 個檔,雲端只有 101 張卡,
// 畫面卻說 20 份沒送——這幾個數字到底是怎麼回事?」)──────────────────────
//
// Progress=總量進度快照(見 progress.go 的 SyncProgress(*Manifest).Progress())。
// 由 RunDirectOnce 跨帳號、跨資料夾 Add() 累加,並把 G-6.2 的 SkippedDocCount
// 併進 UnreadableTotalProgress() 算不到「根本沒進 manifest」的檔)。
//
// FailureBreakdown=「送不上去」(Stuck+Unreadable)展開後的分類統計——
// 只有分類與份數,沒有檔名、沒有解法(取代 08-06 那套逐檔 humanizeFailure)。
//
// ⚠️ 與 SkippedDocCount 同類,**都不進 CarryForwardActivity**manifest 每輪
// 重建、涵蓋現況所有檔,這裡原地算出來就是對的——也因此斷網/閒置一輪後
// 不會被清成 0(現況快照,不是本輪計數)。
Progress SyncProgress `json:"progress"`
FailureBreakdown FailureBreakdown `json:"failure_breakdown"`
}
// FolderPlanStatus=某個看守資料夾這一輪用了什麼收檔策略、據此少收了什麼
// arcrun-rag#1042026-08-16 補接線)。
//
// 🔴 為什麼補這個:#104 第一階段的收工留言宣稱「策略、理由、擋掉幾個檔…
// 都經 TriggerPayload.Plan 走進 status.json」,**但那從來沒有發生過**——
// `ExcludedByPlan` 與 `Plan` 在整個 repo 裡只有 `main.go`CLI,走 stderr)讀過,
// daemon`direct.go`,也就是 App 真正在跑的那條路)拿到 payload 之後直接把這兩欄丟掉。
// ⇒ 使用者接上一個兩千檔的專案、只看到九份進度,畫面**一個字都不會解釋**。
//
// 這是與本票真兇同一天、同一個形狀的第四例:**東西做好了,但不在會被執行的那條路上。**
// 差別只在前三例是「沒接上」,這一例是「接了一半——CLI 有,使用者走的那條沒有」。
type FolderPlanStatus struct {
Mode string `json:"mode"` // allcurated-wikidocs-only
Reason string `json:"reason"` // 一句話講給使用者聽的「為什麼只收這些」
// ExcludedFiles=走進去了但逐檔被策略擋下的數量。
ExcludedFiles int `json:"excluded_files"`
// ExcludedDirsExcludedDirCount=整棵被剪掉的目錄與理由(清單有上限,總數看 Count)。
ExcludedDirs []ExcludedDir `json:"excluded_dirs,omitempty"`
ExcludedDirCount int `json:"excluded_dir_count"`
// OtherWikiDirs=底下其他子專案自己的知識庫,刻意不收但一定要講。
OtherWikiDirs []string `json:"other_wiki_dirs,omitempty"`
}
// MaxSkippedListedstatus.json 裡最多逐檔列幾個。
// 超過的只反映在 SkippedDocCount,UI 說「…等 N 個」——避免整批舊 Office 檔
// 把狀態檔撐大,也避免畫面變成一面看不完的檔名牆。
const MaxSkippedListed = 20
// ExtractFail 記一筆萃取失敗(路徑+白話原因)。
type ExtractFail struct {
Path string `json:"path"`
Error string `json:"error"`
}
// StatusFilePath 回傳狀態檔路徑:與 manifest 同目錄的 status.json。
func StatusFilePath(manifestPath string) string {
return filepath.Join(filepath.Dir(manifestPath), "status.json")
}
// SaveSyncStatus 寫入(覆蓋)狀態檔。失敗只印 stderr,不擋看守本體。
func SaveSyncStatus(path string, s SyncStatus) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, _ := json.MarshalIndent(s, "", " ")
return os.WriteFile(path, data, 0o644)
}
// CarryForwardActivity 決定「最近一次有做事」那三個欄位的值。
//
// 🔴 2026-08-05 leo 實撞:「拖新檔進資料夾,完成本地萃取、上傳,但自始至終 daemon 的
// 首頁都顯示『等待中』…實際上已經做完了,這個 status 是壞的」。
// 真兇:ExtractedOK/ExtractFailed 是**本輪**計數,每輪覆寫整個 status.json
// ⇒ 做完事的那輪寫下 N,下一輪(十幾秒後)沒事做就把它蓋成 0,
// 使用者看到的畫面永遠是「什麼都沒發生」。
//
// 規則:本輪有產出 → 記本輪;本輪沒事做 → 原樣沿用上一輪的(不清空)。
func CarryForwardActivity(prev SyncStatus, st *SyncStatus) {
if st.ExtractedOK > 0 || st.ExtractFailed > 0 {
st.LastActivityAt = st.LastSync
st.LastActivityOK = st.ExtractedOK
st.LastActivityFailed = st.ExtractFailed
return
}
st.LastActivityAt = prev.LastActivityAt
st.LastActivityOK = prev.LastActivityOK
st.LastActivityFailed = prev.LastActivityFailed
}
// LoadSyncStatus 讀取狀態檔;不存在或解析失敗回零值+error(托盤自行降級)。
func LoadSyncStatus(path string) (SyncStatus, error) {
var s SyncStatus
data, err := os.ReadFile(path)
if err != nil {
return s, err
}
err = json.Unmarshal(data, &s)
return s, err
}
// SyncNowSignalPath 回傳立刻同步訊號檔路徑:與 manifest 同目錄的 sync-now。
// tray 寫入此檔 → collector 偵測到後立刻跑一輪同步並刪除它。
func SyncNowSignalPath(manifestPath string) string {
return filepath.Join(filepath.Dir(manifestPath), "sync-now")
}