97309b5580
票上寫的真兇是錯的。`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>
209 lines
13 KiB
Go
209 lines
13 KiB
Go
// 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"`
|
||
// t215(2026-08-08,leo:「在每個知識庫上顯示是否要更新」):這個帳號的雲端知識庫
|
||
// 有沒有新版可以裝——與 portal 版本卡同一套判準(EvalCloudUpdate,cloud_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"`
|
||
// 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"`
|
||
}
|
||
|
||
// 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
|
||
// t104:per-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-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"`
|
||
}
|
||
|
||
// FolderPlanStatus=某個看守資料夾這一輪用了什麼收檔策略、據此少收了什麼
|
||
// (arcrun-rag#104,2026-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"` // all/curated-wiki/docs-only
|
||
Reason string `json:"reason"` // 一句話講給使用者聽的「為什麼只收這些」
|
||
// ExcludedFiles=走進去了但逐檔被策略擋下的數量。
|
||
ExcludedFiles int `json:"excluded_files"`
|
||
// ExcludedDirs/ExcludedDirCount=整棵被剪掉的目錄與理由(清單有上限,總數看 Count)。
|
||
ExcludedDirs []ExcludedDir `json:"excluded_dirs,omitempty"`
|
||
ExcludedDirCount int `json:"excluded_dir_count"`
|
||
// OtherWikiDirs=底下其他子專案自己的知識庫,刻意不收但一定要講。
|
||
OtherWikiDirs []string `json:"other_wiki_dirs,omitempty"`
|
||
}
|
||
|
||
// 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")
|
||
}
|