Files
arcrun-collector/sync_status.go
T
Leo b3f63f40fb feat(t210): 首頁改統計,不再逐檔解釋——Evan「9000/101/20 兜不起來」的病
地基(progress.go 的 SyncProgress/ClassifyFailure/BuildFailureBreakdown,
eeb35ba)已經算好「總量」與「失敗分類」,但沒人接到畫面上:首頁仍在用
本輪計數(extractedOK)+逐檔白話翻譯(humanizeFailure),導致 leo 08-08
轉述的病——三個數字互相對不起來,使用者無法判斷「還在跑」還是「壞了」。

接線(collector/direct.go):
- runDirectOnceRoot 現在也回傳這一根資料夾的 SyncProgress/StuckReasons
  (rootProgress),RunDirectOnce 跨帳號跨資料夾 Add() 累加成總量。
- G-6.2 的 SkippedDocCount(讀不了的檔,根本沒進 manifest)併進
  Unreadable/Total——這是 Progress() 算不到的部分,由呼叫端補齊,
  維持不變式 Total == Done+Pending+Stuck+Unreadable。
- 「送不上去」的分類統計=Stuck 的 LastError 原文+Unreadable 重用
  convert.go 既有的 ErrUnsupported,一起餵給 BuildFailureBreakdown。
  分類判斷全程只經過 progress.go 的 ClassifyFailure 一個接縫,
  direct.go/app.go/前端都不認得任何分類名稱字串(留給 t214 之後
  改資料驅動時只動一個檔)。
- SyncStatus 新增 Progress/FailureBreakdown 兩個欄位,兩者都是每輪從
  manifest/掃描結果原地重算的現況快照,不進 CarryForwardActivity——
  斷網或閒置一輪不會被清成 0。

畫面(cmd/arcrun-app/app.go+frontend/src/main.js):
- 移除 humanizeFailure/buildFailures/UIFailures 那套逐檔白話翻譯,
  改用 UIProgress(Total/Done/Pending/CantSync/Groups);前端 cardProgress
  只把後端給的 category/count 陣列原樣印出,不分支、不排序、不認分類名。
- 「送不上去」預設摺疊(<details>),展開只有分類與份數,不逐檔列名、
  不解釋、不給解法;細節導向「開啟使用說明」。
- 保留 buildSkipped 的「讀不了的檔」卡片(那是另一件事),但份數已併入
  Unreadable。
- 移除「總計」卡片裡用本輪計數 extractedOK 的「份已整理」——上方狀態
  時間軸的「上次 N 份」與下方矛盾(1 份 vs 0 份已整理)的病因直接消掉,
  改用 cardProgress 的累計「已送上去」。

驗證:
- go build ./... 與 go test ./...(collector/cmd/arcrun-app/
  cmd/arcrun-tray 三個 module)全綠。
- 新增 collector/progress_wiring_test.go:真跑 RunDirectOnce 湊出
  Done/Pending/Stuck/Unreadable 四種狀態同時存在,驗四數字相加等於
  總數(leo 驗法①);再跑一輪「什麼都沒發生」驗數字不歸零(驗法③)。
- check-render.sh 視覺機械閘綠(lockup 底板/深色模式)。

殘項(誠實標記,未完成):
- 真機驗收(leo 08-08 驗法④:拿 Evan 的情境走一遍)未做,需要 leo 或
  封測者在實機驗證。
- 「開啟使用說明」目前連到既有 docs 首頁,尚無 t210 分類對應的 FAQ 頁
  (tasks.md 已記為相依項)。
- t213 診斷檔尚未消費這組新欄位(tasks.md 記載該任務等本任務讓路)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 12:45:12 +08:00

155 lines
9.1 KiB
Go
Raw 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"`
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"`
}
// 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
// t104per-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-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"`
}
// 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")
}