// 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"` ExtractedOK int `json:"extracted_ok"` ExtractFailed int `json:"extract_failed"` // t182(leo 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-DD(UTC,與額度重置同一條日界線) 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"` } // 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) CloudVersion string `json:"cloud_version,omitempty"` // bundle_version(單帳號時填) CloudCheckOK bool `json:"cloud_check_ok"` // /health 可達才為 true // t104:per-account 狀態(key = instanceHostOf(cypher_url)) AccountDetails map[string]AccountSyncStatus `json:"account_details,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"` // ── t210 統計層(2026-08-08,Evan 封測:「9000 個檔,雲端只有 101 張卡, // 畫面卻說 20 份沒送——這幾個數字到底是怎麼回事?」)────────────────────── // // Progress=總量進度快照(見 progress.go 的 SyncProgress/(*Manifest).Progress())。 // 由 RunDirectOnce 跨帳號、跨資料夾 Add() 累加,並把 G-6.2 的 SkippedDocCount // 併進 Unreadable/Total(Progress() 算不到「根本沒進 manifest」的檔)。 // // FailureBreakdown=「送不上去」(Stuck+Unreadable)展開後的分類統計—— // 只有分類與份數,沒有檔名、沒有解法(取代 08-06 那套逐檔 humanizeFailure)。 // // ⚠️ 與 SkippedDocCount 同類,**都不進 CarryForwardActivity**:manifest 每輪 // 重建、涵蓋現況所有檔,這裡原地算出來就是對的——也因此斷網/閒置一輪後 // 不會被清成 0(現況快照,不是本輪計數)。 Progress SyncProgress `json:"progress"` FailureBreakdown FailureBreakdown `json:"failure_breakdown"` } // MaxSkippedListed:status.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") }