sync: collector/ 同步自 inkstone/arcrun-rag@740c55c(桌面小幫手 0.18.44)

This commit is contained in:
2026-08-28 03:46:42 +08:00
parent ef5fe1df6f
commit 80877af60c
14 changed files with 878 additions and 52 deletions
+76 -15
View File
@@ -114,6 +114,12 @@ type DirectConfig struct {
// 所以改名不會讓庫裡憑空多出一台機器。
MachineLabel string `json:"machine_label,omitempty"`
// guard=這一輪的「等待閘」(見 stallguard.go)。RunDirectOnce 每輪換一份新的;
// makeAccountSubConfig 的 `sub := *c` 會把這個**指標**一起帶過去,所以同一輪的
// 所有帳號、所有資料夾共用同一份紀錄——斷路器要跨得了資料夾才擋得住「一發卡住
// 就整輪停擺」。不落 config 檔(它是這一次執行的狀態,不是使用者設定)。
guard *roundGuard
// machine=解析好的機器身分快取(不落 config 檔:ID 的家是 machine.json
// 這裡只是這一輪的記憶體副本。makeAccountSubConfig 的 `sub := *c` 會一起複製,
// 所以多帳號同一輪只解析一次、每個帳號送出的值必然一致)。
@@ -467,7 +473,11 @@ func (c *DirectConfig) migrateManifestIfNeeded(absRoot, newPath string) {
}
}
// directHTTP 是 direct 模式共用的 HTTP client(萃取 workflow 可能同步跑 LLM,放寬 timeout
// directHTTP 是 direct 模式共用的 HTTP client。
//
// 🔴 這把 Timeout 是**最後一道**保險,不是每一發的上限(`inkstone/arcrun-rag#153`):
// 真正生效的上限由呼叫端的 callStep 帶進 context(見 stallguard.go),因為
// 「送一份筆記」跟「請雲端同步跑完 AI 萃取」本來就不該共用同一個數字。
var directHTTP = &http.Client{Timeout: 300 * time.Second}
// triggerURL 組出 named-webhook 觸發完整 URL。
@@ -505,12 +515,23 @@ func countsAsDocument(r DirectResult) bool {
}
// postJSON POST 一個 JSON body 到 url,回傳 HTTP 狀態碼與回應片段。非 2xx 視為錯誤。
func (c *DirectConfig) postJSON(url string, body any) (int, string, error) {
//
// step 講的是「這一發在做什麼」(`inkstone/arcrun-rag#153`):它決定這一發自己的
// 上限,也決定卡住時畫面與日誌上那句話怎麼寫。同一個網址在不同地方是不同的事
//(收卡那條路,可能是使用者剛存的新檔,也可能是在補修舊筆記的出處)——
// 所以 step 由呼叫端給,不從網址反推,反推出來的名字會說謊。
func (c *DirectConfig) postJSON(step callStep, url string, body any) (int, string, error) {
data, err := json.Marshal(body)
if err != nil {
return 0, "", err
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
gate := c.openGate(step)
defer gate.release() // context 要活到下面讀完回應為止,所以是 defer 不是就地釋放
// 這一輪已經判定這個帳號沒有回應 ⇒ 連打都不打,立刻回頭讓其他資料夾繼續。
if note := gate.blocked(); note != "" {
return 0, "", errors.New(note)
}
req, err := http.NewRequestWithContext(gate.ctx, http.MethodPost, url, bytes.NewReader(data))
if err != nil {
return 0, "", err
}
@@ -518,7 +539,7 @@ func (c *DirectConfig) postJSON(url string, body any) (int, string, error) {
req.Header.Set("X-Arcrun-API-Key", c.APIKey)
resp, err := directHTTP.Do(req)
if err != nil {
return 0, "", err
return 0, "", gate.record(err)
}
defer resp.Body.Close()
// 🔴 讀 64KB 而不是 1KB:觸發端點的回應是一層外殼包著工作流的輸出,
@@ -611,6 +632,12 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
var lastPayload *TriggerPayload
now := time.Now() // 2026-08-07 pacing task:整輪共用同一個時間點(排序/冷卻判斷一致、好測試)
// 🔴 `inkstone/arcrun-rag#153`:這一輪的「等待閘」。每輪換一份新的——斷路器只管
// 這一輪,下一輪一律從零開始重新試(同步是 level-triggered 的,沒有什麼要記住)。
// 指標會隨 makeAccountSubConfig 的 `sub := *c` 傳給每個帳號、每個資料夾,
// 所以「這個帳號沒有回應」這件事跨得了資料夾——那正是本票要修的那條線。
cfg.guard = newRoundGuard()
// 2026-08-07:提早載入上一輪 status(原本只在函式尾端載入做 CarryForwardActivity)。
// 額度冷卻與「今天已萃幾份」是**跨輪持續的狀態**quotaState 見 quota.go),
// 要在處理帳號之前就知道上一輪冷卻到什麼時候、今天已經算到幾份。
@@ -757,7 +784,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
// 更新進度不同步),一個帳號通了不代表另一個也通。
// 只在走 workers-ai 這條路時掃;選了 Gemini 的人不需要知道這件事。
if accCfg.Extractor == "workers-ai" {
state := ProbeWorkersAI(accCfg.CypherURL, accCfg.APIKey)
state := accCfg.probeWorkersAI()
accSt.CloudAIReady = state.Ready
accSt.CloudAINote = state.Note
if !state.Ready && state.Note != "" {
@@ -896,6 +923,8 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
AccountDetails: accountDetails,
Retiring: retiring, // arcrun-rag#46:移除並收回中的資料夾進度
Resync: resync, // arcrun-rag#140:雲端上找不到、正在自動補送的資料夾
// #153:這一輪等太久的事。空=沒有人在等,畫面上不佔位置。
Stalls: cfg.guard.Stalls(),
}
// G-6.2:把「讀不了的檔」寫進狀態檔,App 首頁才有東西可以講。
// 排序=畫面每輪穩定(map 迭代順序隨機,不排的話清單會自己跳動)。
@@ -1109,11 +1138,17 @@ func drainPendingTakedowns(
}
return results, exit
}
// #153:同一條撤除路徑服務兩件事,而使用者眼中它們不是同一件——
// 「我刪了一個檔」跟「我把整個資料夾收回來」卡住時該說的話不一樣。
step := stepTakedown
if resultType == "folder_takedown" {
step = stepRetire
}
for oldPath, pageName := range m.PendingTakedowns {
pace()
res := DirectResult{Type: resultType, Path: oldPath}
mach := cfg.machineIdentity()
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.RemovedWF), map[string]any{
status, _, perr := cfg.postJSON(step, cfg.triggerURL(cfg.RemovedWF), map[string]any{
"page_name": pageName,
"path": oldPath,
"library": cfg.libraryFor(absRoot),
@@ -1567,6 +1602,18 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
results = append(results, res)
continue
}
// 🔴 #153:這個**帳號**這一輪已經被判定沒有回應 ⇒ 連試都不試。
// 與上面的額度冷卻同一層、同一個理由:這不是這個檔的問題,
// 記進它的病歷(FailCount/退避階梯)會讓一次雲端沒回應,
// 變成一整批檔案「已放棄自動重試」——那是把別人的停機算在使用者頭上。
// 沒有這道閘的話,一個沒有回應的帳號會讓這一輪繼續逐檔去撞,
// 每撞一次就是一個 Budget,25 個檔就是幾十分鐘。
if note := cfg.unreachableNote(); note != "" {
res.Status = "skipped"
res.Error = note
results = append(results, res)
continue
}
// 🔴 t195 止血點:這個檔剛失敗過且還在退避窗口內 → 這輪跳過。
// 沒有這道閘時的實測災情:`小果被AFTEE詐貸.pdf` 因雲端 401 失敗,
// 每輪重掃又被當成新檔 ⇒ **1387 輪、跨 11 小時**,且它排在佇列前面,
@@ -1619,7 +1666,19 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
// ⇒ 探測在 RunDirectOnceProbeWorkersAI),結果寫進 status.json
// 托盤那行「狀態:」直接告訴用戶該做什麼。
// 靜默退回會讓用戶**永遠不知道自己的雲端還沒更新**——正是要避免的黑箱。
// #153:萃取也是一發會等很久的網路呼叫,而 workers-ai 打的正是
// **使用者自己的那台雲端實例**——跟上面那些收口是同一台。
// 它有自己的 client timeout,但沒有人在數「這個帳號已經連續幾發
// 等不到回覆」⇒ 漏掉這一格的話,單輪上限 25 個檔會變成 25 次
// 各自的等待,同一輪照樣走不完。
//
// 🔴 gemma 那條**刻意不掛**:它打的是 Google,不是使用者的知識庫。
// 掛上去的話,Google 慢會被算成「你的知識庫沒有回應」——
// 誤導的訊息比沒有訊息更貴(會害人往錯的方向查)。
xgate := cfg.openGate(stepExtractDoc)
cards, xerr = ExtractWithWorkersAI(cfg.CypherURL, cfg.APIKey, absRoot, ev.Path, cardOrigin)
xgate.release()
xerr = xgate.record(xerr) // 只有「等到超時」會被記帳,其餘錯誤原樣往下走
case "gemma":
cards, xerr = ExtractWithGemma(cfg.GeminiAPIKey, cfg.LLMModel, absRoot, ev.Path, cardOrigin)
default:
@@ -1700,7 +1759,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
cardBody["quality"] = "low"
cardBody["quality_warnings"] = warns
}
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.CardIngestWF), cardBody)
status, _, perr := cfg.postJSON(stepIngestCard, cfg.triggerURL(cfg.CardIngestWF), cardBody)
res.HTTPStatus = status
if perr != nil {
res.Status, res.Error = "failed", perr.Error()
@@ -1743,7 +1802,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
// ⚠️ 雲端這支 workflow 本輪**沒有跟著改**youlin stage 上根本沒部署它,
// 現役是 rag_ingest_card)——它會忽略這兩個欄位,行為與從前一字不差。
machDirect := cfg.machineIdentity()
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.IngestWF), map[string]any{
status, _, perr := cfg.postJSON(stepIngestDoc, cfg.triggerURL(cfg.IngestWF), map[string]any{
"page_name": pageNameOf(ev.Path),
"path": ev.Path,
"content": string(content),
@@ -1780,7 +1839,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
// 連坐殺掉 B 機器同名檔」,而多補一維會改變既有的撤除命中範圍——
// 那是另一件事,要另外驗(本輪不驗的不做)。
machRm := cfg.machineIdentity()
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.RemovedWF), map[string]any{
status, _, perr := cfg.postJSON(stepTakedown, cfg.triggerURL(cfg.RemovedWF), map[string]any{
"page_name": pageNameOf(ev.Path),
"path": ev.Path,
"machine": machRm.ID,
@@ -1919,20 +1978,22 @@ func runDirect(args []string) int {
// ⇒ 開工前先印一筆 `phase:"start"`,托盤收到就顯示「同步中…」,
// 收到 `phase:"done"` 再切回「看守中」。
// 形狀相容:兩筆都有 `at`,舊版托盤只會多算一次 round,不會壞掉。
startOut, _ := json.MarshalIndent(struct {
//
// 🔴 #153:這兩筆改走 printJSONLine——「還在等」的播報跑在另一條 goroutine 上,
// 而 done 那筆可能有幾十 KB。共用同一把鎖,兩邊才不會把彼此的 JSON 切成兩半
//(切壞一次,supervisor 的 decoder 就再也讀不到這個行程的任何一筆,見 stallguard.go)。
printJSONLine(struct {
At string `json:"at"`
Phase string `json:"phase"`
}{time.Now().Format(time.RFC3339), "start"}, "", " ")
fmt.Println(string(startOut))
}{time.Now().Format(time.RFC3339), "start"})
results, exit, _ := RunDirectOnce(cfg, *dryRun)
out, _ := json.MarshalIndent(struct {
printJSONLine(struct {
At string `json:"at"`
Phase string `json:"phase"`
Folders []string `json:"folders"`
Results []DirectResult `json:"results"`
}{time.Now().Format(time.RFC3339), "done", cfg.Folders(), results}, "", " ")
fmt.Println(string(out))
}{time.Now().Format(time.RFC3339), "done", cfg.Folders(), results})
return exit
}