3 Commits

25 changed files with 2098 additions and 145 deletions
+29 -1
View File
@@ -47,7 +47,35 @@
---
## 下一版(未發佈
## 0.18.482026-08-28
- 🔴 **資料夾那一列不再是一整排字**:以前每個資料夾後面掛著「補送中」「資料夾結構」「移除」三個詞,底下再壓一句長說明,七個資料夾就是七段文字。現在那一列只剩三個圖示,同步好了就是一個打勾,滑過去才會告訴你細節。
- **打勾是真的**:以前只要沒有錯誤訊息就顯示「自動同步中」,連空資料夾、連小幫手還沒回報的資料夾都一樣。現在那個記號直接對應「這個資料夾裡幾份進去了/總共幾份」,數字對不上就不會給你打勾。
- **一次連線失敗不再讓知識庫版本消失**:以前只要有一輪沒問到,畫面就說「目前連不上這個知識庫,查不到版本」——即使它下一秒就通了。現在問不到只影響「有沒有新版可更新」,你原本的版本號還在。
## 0.18.472026-08-28
- 這一版你不會看到任何不一樣:收檔、整理、同步、畫面全部跟上一版相同,不必特地更新。改的是我們自己出貨前的自我檢查。以前每次重新編譯程式,就會多出兩顆二十幾 MB 的編譯結果混進原始碼裡,害那道檢查誤判成「程式又被改過了」而擋下出貨——實際上一行程式都沒動。現在編譯出來的東西不再混進去,那道檢查量到的就真的只有程式本身。
## 0.18.462026-08-28
- 修好上一版帶進來的問題:雲端只是回得慢,卻被講成「沒有回應」,還害後面的檔被跳過。上一版我替每一種請求設了時間上限,其中「送出一份筆記」給了 60 秒——但實際量出來它本來就要 33 到 57 秒。結果尾巴被剪掉,兩份檔判成失敗,接著整個帳號被判定沒回應,**後面 9 份檔一份都沒送**。這一版把上限依實測值放寬(送知識的請求 5 分鐘、只登記結構的 3 分鐘),並且只有在**連續**等不到回覆時才會暫停該帳號——中間只要成功過一次就重新計算。
- 訊息也改了:只要這一輪它成功回應過,就不會再說「沒有回應」,而是說「回得太慢」。以前那句話會讓你去查網路、去重裝,全都白費。
## 0.18.452026-08-28
- 資料夾結構現在幾秒內就會更新,不用等整輪同步跑完。以前這棵樹是**排在所有雲端工作的最後**才寫下來的,而一輪同步實測要十幾分鐘到好幾小時(每一次跟雲端說話就要 24〜33 秒,檔案越多輪越久)——所以你看到的是「小幫手開著、沒有錯誤訊息、資料夾結構永遠停在上一版」。那不是卡住,是這一輪還沒輪到寫它。現在它一掃完就先寫,該送上雲端的照舊。
- 這一版沒有改收什麼檔、也沒有改整理的方式。只改了「畫面上的東西什麼時候更新」。
## 0.18.442026-08-28
- 一個帳號沒有回應,不會再讓整台機器的同步停下來。以前只要有一發請求卡住,後面所有知識庫帳號、所有資料夾就一起停在那裡,畫面上什麼都不動也沒有錯誤訊息(實測卡了 39 分鐘)。現在等不到回覆的那個帳號這一輪先跳過,其他資料夾照常跑完。
- 等待不再是靜默的。哪個知識庫、正在做哪件事、已經等了多久,會寫在畫面與紀錄裡。
- 資料夾結構每一輪都會重新整理。以前雲端一沒回應,你看到的資料夾樹就永遠停在上一次。
- 被跳過的檔案不算失敗。雲端沒有回應是知識庫那邊的狀況,不會被記成這個檔的失敗次數,也不會因此被停掉自動重試。
## 0.18.432026-08-28
- 🔴 **小幫手不再把自己整理出來的筆記當成新資料再整理一次**:以前它整理完一份文件、把筆記寫進資料夾之後,下一輪掃描又會把那些筆記當成「新來的檔案」再整理一遍,整理出來的東西又變成下一輪的材料——一輪接一輪停不下來。你會看到資料夾裡冒出越來越多名字奇怪的檔(像是同一個詞被括號包起來重複一次),每一輪都白白用掉一次當天的整理額度,知識庫裡也被塞進一堆「筆記的筆記」。現在它認得出哪些檔是自己寫的,一律跳過。
- **已經被繁殖出來的那些檔要自己刪**:這次的修正只保證不再繼續長,**不會回頭清掉先前多出來的檔**——那些檔跟你自己的東西混在同一個資料夾裡,我們寧可不替你決定刪哪一個。名字裡有重複括號的(例如「換柱(換柱)」)就是這次多出來的,可以直接刪掉。
+12 -2
View File
@@ -30,6 +30,7 @@ package collector
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -98,7 +99,15 @@ func cloudCardPresent(cfg *DirectConfig, library, relPath string) (present bool,
if strings.TrimSpace(library) != "" {
q.Set("library", library)
}
req, err := http.NewRequest(http.MethodGet, base+"/kbdb/entries?"+q.Encode(), nil)
// #153:對帳一輪可能連打 cloudAuditBatch(20)發。「一發卡住」的代價在這裡
// 會被乘上批次大小 ⇒ 20 秒的 client 逾時最壞就是 400 秒,而這期間畫面一個字
// 都不會說。掛上等待閘:等到超時就記帳,同一個帳號連續等不到就整輪不再問。
gate := cfg.openGate(stepCloudAudit)
defer gate.release()
if note := gate.blocked(); note != "" {
return false, false, errors.New(note)
}
req, err := http.NewRequestWithContext(gate.ctx, http.MethodGet, base+"/kbdb/entries?"+q.Encode(), nil)
if err != nil {
return false, false, err
}
@@ -106,8 +115,9 @@ func cloudCardPresent(cfg *DirectConfig, library, relPath string) (present bool,
req.Header.Set("X-Arcrun-API-Key", cfg.APIKey)
resp, err := cloudAuditHTTP.Do(req)
if err != nil {
return false, false, err
return false, false, gate.record(err)
}
gate.ok()
defer resp.Body.Close()
body, rerr := io.ReadAll(io.LimitReader(resp.Body, 8192))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+14
View File
@@ -16,3 +16,17 @@ dist-msix/
# Python 匯入快取(daemon-version.py 被 import 時產生)。可重現的產物,且綁 Python 版本
# (檔名帶 cpython-314)⇒ 不進版控。2026-08-18 D95 第三輪:第一輪搬檔時誤把一顆帶了進來。
__pycache__/
# 🔴 `go build ./...` 在本目錄留下的執行檔(inkstone/arcrun-rag#1582026-08-28)。
# Go 對「單一 main 套件」會把成品寫成**目錄名**,所以任何人在這裡跑一次 go build
# 就會生出 `arcrun-app` 這顆 20 MB 的二進位。它一路被追蹤到 2026-08-28 為止。
#
# 為什麼非拿掉不可(實測,不是推論):這顆檔案**在版本指紋的涵蓋範圍內**
# `daemon-version.py` 的 `scan_source()` 走 `git ls-files -co --exclude-standard`
# ⇒ 只是重編一次、一行原始碼都沒改,指紋就從 1325784830f592ff 變成 eba122f83317b67d
# ⇒ 出貨線的 `daemon-source-check` 判「0.18.46 打包之後,1 個檔案變動過」而擋下。
# **把編譯產物算進「原始碼指紋」,那個指紋就量不到它宣稱要量的東西。**
#
# ⚠️ 錨在本目錄(開頭的 `/`):不要寫成 `arcrun-app`,那會連子目錄裡同名的東西一起蓋掉。
# 打包腳本要的產物在 `build/bin/`(早就 ignore),不是這一顆——三支腳本都沒引用它。
/arcrun-app
+39 -29
View File
@@ -1,28 +1,29 @@
{
"_algo": 4,
"version": "0.18.42",
"fingerprint": "43e0c6ac08233f07",
"version": "0.18.48",
"fingerprint": "a440a58a629f960c",
"files": {
".gitignore": "4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97",
"CHANGELOG.md": "6cbc29aad162ab41f6db0ef6550bf6d5c44c01544598816d33b58ad585446528",
".gitignore": "c7a98c44ecc882a174720e43dabd4c04d7cf2503586426dcccaa213d5a27df38",
"CHANGELOG.md": "51b85dc1e96ce190a8652df8a696bd664229b1c5bcb6cf695ced905dc5956d1b",
"DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d",
"README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f",
"cardloop_test.go": "fdf09b62650c328f5d8075d964a0ca2dada5f8d9a271202768472998b45e31a8",
"cardloop_vs_existing_wiki_test.go": "1fc42120114902571f61f65a56dc561bd2d858096c25303ce564a891a7a2511d",
"check-standalone.sh": "65fbce096326791c2f103a51e76d0ad79e9e510e2a7700b49ac2d2d81f73c853",
"cleanup.go": "59a45672f7f73d73833685846148ba20f71d5b3f42340be5a74a10437be267fc",
"cleanup_cli.go": "5dc41aac30f39757658d7bc95ab727ecc799b8242fd752edcedbb8423935fbf0",
"cleanup_test.go": "23d627a12ec476bb8898847185a1464cb78e2186278299ff13c84a3d54af63c0",
"cloud_audit.go": "a51f5c6eafaa75ce282b307e8dd2986f0b316a3c24282bed0856002819dd86e2",
"cloud_audit.go": "e3dbf32785e3d6643a6d62c298c4ece2680d8f00c1148f6d55285dd670d9cf51",
"cloud_audit_test.go": "1790837e8a5726d62258436937f9c474f7abd03e6229832fdbd40f8cd7aa756a",
"cloud_latest.go": "c2ad7f87bbd55247540eb924ac3be5deadbc4488b504770a5127a01c748f0e0f",
"cloud_latest_test.go": "703963b65da91ae0c4638b9ad676ca7f12a474c635e903a5d988e4980b3888aa",
"cloud_version.go": "eebc2552e09e199928e82607bebf90daef36b5721ec9b8a098304c10dbec46f9",
"cloud_version_test.go": "66c004de1f0a3958851f22a22367065f13b3381ac7d936ae54d812c26a0b9fb6",
"cmd/arcrun-app/.gitignore": "7502ba597654ebb3463535bdfb51c271abad666a67c7ac8ab36deca1a72615f8",
"cmd/arcrun-app/.gitignore": "91248524c8b00d155960f42ad6707ce36bd91ff44f66289eec45972d3915ad37",
"cmd/arcrun-app/README.md": "23e53fdf53fcd96b6d8d7a121eb91d3242d11b69d655770473b745c49bb387b9",
"cmd/arcrun-app/app.go": "274129ed77c4f4194f6b2007cb47e4ffe536b15b6450fe89b14fbb31e6a16395",
"cmd/arcrun-app/app.go": "098e6a01aa0a298248ae9cc96275b209e5b8c9669325ba691c46383470216698",
"cmd/arcrun-app/apps.go": "0df6da9d55adbdec500fbb01005a305287d1dbf796b7e463f2b2f222626b4c96",
"cmd/arcrun-app/apps_test.go": "0e2f137f8b49f421e869ab3192ca12c8bb756029d8730487ccc44733aaf7f96c",
"cmd/arcrun-app/arcrun-app": "e2bdf54a15d69860df05820274a80d7497b7d62a4e8ba219f72f30b543456b79",
"cmd/arcrun-app/assets/store/README.md": "a9028a727947c5038ca938c664b18e4851ae3bf0e6d8838c82aedea43d02ded2",
"cmd/arcrun-app/assets/store/Square150x150Logo.png": "27942d943096ba23f036b030726e905f6df6123428cd7d8ec687ce5b11e42db1",
"cmd/arcrun-app/assets/store/Square310x310Logo.png": "f610d521767347974fcd68006055d06c5366e99c247d31a2b9221bae34d67798",
@@ -70,10 +71,12 @@
"cmd/arcrun-app/diagnostics_export.go": "19328a384d653a24c557f5ee7497d62f1c1eaeba37976819b07e44f002b8862b",
"cmd/arcrun-app/diagnostics_export_test.go": "00fbd5a436a82652d28f9974d29dcbaf57a432ec0df05bf491e6f9755bbff00b",
"cmd/arcrun-app/diagnostics_stage_manual_test.go": "e1c9fe6b8d6d6e39c74efec5c8874bf4a7922cf5b03789e49c40ef24231d1f00",
"cmd/arcrun-app/dist/Arcrun-v0.18.3.dmg": "bfe9282ca591b62390e53e439a9f97ed71e6c7c7ece2dd61e10b75e77b0ae991",
"cmd/arcrun-app/dock_darwin.go": "25f07f57eea6dbacdc4eaf652ad56b83b4641ec6392f6a4a4943b67f3f24156a",
"cmd/arcrun-app/dock_other.go": "b5f7bb020ec20c9ed27436b5be15aa16798a180d1bf200b4158c5bdbfe3e6be7",
"cmd/arcrun-app/firstrun_test.go": "90d74db3e6a7b77617d50559a181229c0e720c20ead35b9554cb72e5212c7303",
"cmd/arcrun-app/folder_badge.go": "ee2b3a62fc7424816268267e573be5b59174d1fcc3a492dded6b9b220506b1a5",
"cmd/arcrun-app/folder_badge_fixture_test.go": "38c74b7571f60d24c3d1b337e79f38df153b589f3cd911cfbaf8369e4759ba18",
"cmd/arcrun-app/folder_badge_test.go": "61377b6cbf51be32ee5b4fdc40724afd9a040e1041886c96f364d480bdd02801",
"cmd/arcrun-app/freshinstall_e2e_test.go": "59d8c8c75365f7821f838baca9abd93f167bebd0998779a54c5fe33e390e7331",
"cmd/arcrun-app/frontend/index.html": "3cbf1ec22539e0e0ad7e8e86b1857cf752e042ecfd52e816f7d2bdcd7cf99a91",
"cmd/arcrun-app/frontend/package-lock.json": "a4020582cf3fb5f2413451d5d5498a54ceadae153d28e3f59f81f6a7b2c97caa",
@@ -88,8 +91,8 @@
"cmd/arcrun-app/frontend/src/assets/fonts/OFL.txt": "8656c2a27357b3140047a39b27e3cd928256544ad8a004ef77087f761358208c",
"cmd/arcrun-app/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2": "06f3af3fe52542d40ad9bc14ec03e04deaabd09ec369221cc8f536db1c72bf55",
"cmd/arcrun-app/frontend/src/assets/images/logo-universal.png": "157a874acd3363634747287c0b01034e9f1eec838a4cf39827dfea789e730494",
"cmd/arcrun-app/frontend/src/main.js": "277390ab8bf4114c797aa32b2cdae151e86a63681c612c424e73c308c446234e",
"cmd/arcrun-app/frontend/src/style.css": "4fb87b139100b826c02c71a69599da2c4e8b93d3f7855c2a9b10e6677fecfbb2",
"cmd/arcrun-app/frontend/src/main.js": "327877a5bb79b79589e09b902e7002e33d275a413451ff90f230bb2e92418b52",
"cmd/arcrun-app/frontend/src/style.css": "2cc59ab0be7668f2b8e2e42f655beb506a91f2d31414e0a40dbab33fffc8349a",
"cmd/arcrun-app/gen-icons.py": "df5b3b33e500fc564badfc8bd2a9ca6a43f7d7e3b31111c41e3b322137c4c96c",
"cmd/arcrun-app/go.mod": "8b9e57bcfd44886d1ec06480e033af706ff3fed470786c57074900dd48ecf86f",
"cmd/arcrun-app/go.sum": "bc4453654c203e5af251c5a3061616cb0ceeb1b6a78a02ace2ca85524d217256",
@@ -111,7 +114,6 @@
"cmd/arcrun-app/upgrade_repair_test.go": "65413d0b07b6c011de2af2783247d0f602a680a199229b52a77e176626c8ef26",
"cmd/arcrun-app/wails.json": "a6448229972e3194026f6a407a427a5601ce6f5deb43d1fb7c5b5226512d8bc5",
"cmd/collector/main.go": "fd04291f6cd4833f00593a0580baeaa231875da6d26636fca4f51090f0a80c80",
"collector": "ef769ff086a4da691b4bd646de4efb6957204efa829545e43cbcb99bf9503b70",
"convert.go": "fb3b52354b94f527ccffa5600620749a5fa6009834530aac04b7d141bb3e3b4b",
"convert_docx.go": "5d4f459ff866c86ee78424503e1c9a410e1dbfd0ece40d63a603b94b61b7ddaf",
"convert_pdf.go": "e4955008f9bf9e458304fa0baa087ae7a37ac52e9fb66df3e99f22a4c513b9b1",
@@ -122,7 +124,7 @@
"convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394",
"convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18",
"convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b",
"direct.go": "b4dc846cd62b0d57754afeb66658e35f2578afd6ca256e95ee8f72c0f03507b9",
"direct.go": "ba19086aa177884405c038a8841cd5b6b2ef2bf2043d18a708ff7e6c6c9ca45f",
"direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4",
"direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e",
"direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c",
@@ -131,43 +133,47 @@
"direct_retire_test.go": "fbff728362313c546c1fc01cc74094672e22a8171d4bc0329ce1b3d118c82b3c",
"direct_sync_now_test.go": "dc7a6e3d5646a470e889d30f0224cdb86be832b8cf85ffef8629e42ec577622b",
"direct_t182_test.go": "1c64a9d0f41b98504e10a20a96f28e8a47cced4923171ef40dc800b861d0275b",
"extract.go": "76c40068d378d6036e330bb56e41caec2f80b9b219352586744194df560c8147",
"extract.go": "b6b5212ab5107c53da17bf677ac54b040f2f78674b902c8b5750a3befa4a1f23",
"extract_gemma.go": "53483c6ea3992759abbae45562bfc418d4df1728b44fa87b3bd30324d28fc4f4",
"extract_gemma_test.go": "439f12c1c6fb534b31f983305a4d7898ef66dfcbe697714be08a6b571e4e57c0",
"extract_test.go": "63560ecca929c6507d93b3f52988bb7ce2cd6137140eb373c520eac5ee052c76",
"extract_workersai.go": "577ea44f3224b54cac9042dcb11edcc29404313683c3c92d4267b07b82f35097",
"extract_workersai.go": "8300691e1a41df4096d316686efbd0016e8df4d482be55b8ce4388b53bd9e1c5",
"extract_workersai_test.go": "8850e7128f03ecb4754a4663281800dc73d556fab1d80367924b58bae3dd8ec3",
"folderindex.go": "f3dd186fd7e882ff293b3f8aa6b2582b018290ee5f14bbdc02a7ac131834fd69",
"folder_progress_wiring_test.go": "c5a4575395f1cee5d1c0ee58728a034113534a3f0d456e4e0d23563da840d314",
"folderindex.go": "575a56fc7763ad82aef14fc5e0e1df39d351f0903babe6251bf2df990e358089",
"folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734",
"folderindex_test.go": "a5319eb1e92473cb5c32d3beddb7889997556227824de300dd39953b43beab70",
"foldershape.go": "b1b237f5674674efdc9bdea849b63c571a014e64bec33af42fb7c4769fa7582b",
"foldertree.go": "8b3de4fde32e538b3a6a9440f6fb89e5737391a2499668b961a933509b36fa17",
"foldertree.go": "c12461137d6c177db8e1e2f5c7446bcbd6bfd567f044cc07e11e883cc71892dd",
"foldertree_live_test.go": "ace96ffdf7670a69761973e810fb6548c7036d2768141e09db515313df1e9a50",
"foldertree_local_test.go": "ce919dc2a21ec96716e3d43a7f493367346e212243b4ab22c2d960fc26408f3a",
"foldertree_publish_test.go": "104b26ffac1fc78a4c9b061aba3e2167c7d71ea469073537c3d86e3896debdea",
"foldertree_test.go": "f8b8cdf1ef9d7d1c80cabb086fc9b4f2162b91a47d6d78bde2632b07672c0b88",
"foldertree_traversal_test.go": "b9a3ce5c784b7601e628b1299596b3572ca787e654cd2ee20b973d053bab84aa",
"go.mod": "05dacc224af2a6797db86689435f83537efb433ffba521467b95d1099f5e12db",
"go.sum": "a9c92957ca368462aab32a82a6f51c9586ea07478a7967012986d757e9969ab3",
"grounding.go": "7152771ab1bb152946209f8e94e52bdf2facc6192b7a8ca018e37fed4557e9c0",
"grounding_test.go": "46f5fd98297caf45b5db494f69363f2544e1cfa5634ce24fe25ca6a87ae296d2",
"ignorerules.go": "e6923d0fe35e377e75cfe2364a10624ca40aa6d28efe48ea725036769a48ea14",
"ignorerules_test.go": "19675a03539e92e7fd58a46ce8105875cc5aaa42f53615348de775b6d90dbf16",
"ingestplan.go": "0c78c96c3218b8ca3f15bceb3e99c087f055bbd4d2e6024fee6268e3efc46e94",
"ingestplan.go": "f32cd7a740fc291a6b54e2804e0bdf0d50770962414fafe92b015ddf0c6fcddf",
"ingestplan_test.go": "baf57a5edc99482842c16d1ea28d1ad6afe7a236a56fc42dac8d007341b578be",
"ingestplan_wiring_test.go": "4e5d25dace8a181418ed5857423ef1efeacdd98b7ff42b94a71c4e5fd7012e89",
"inventory.go": "d0ba0be3e2af8df3cc6dffac5b75816b9c2a0a12f6d2eff1a786d8584ca77622",
"inventory.go": "707b2b74dccbd082d56793a558bb28ce0f8812d458b221d8b2d6b084b752af50",
"inventory_test.go": "16e0f83a09d61dc4cded032fd493d30bfc747c38116b871fb02b2f0bf2b0216a",
"lint.go": "63af1289978a02c9722ac8bb934a916b9e4e65cffcfec0749eb07590496960f3",
"lint_test.go": "2f57a04b9d697fba2d078eb08f49d2fec1640c7e3c61a60bc3821d2bb0798153",
"machineid.go": "d4224e684ecfbafe2efc7e0b95377f9e669ee1256a442aa72ce2cab3b219fc56",
"machineid_test.go": "5b54ac62ee1fb28b8f0a873888aeddb844af0aa74472bfb849573cfc6691a290",
"machinemark.go": "b93464c2ee89a73c0e0da38bcce5dc1ba6c1deba2fc483f28c6021c3ffdee4a0",
"machinemark.go": "592a39597fd011e8b1603318eaedf452aa58fca0639244a80218aa9a0ac6dbf5",
"main.go": "fe2f69739f3ee92713f8aaca925068ecf97b33d476eee826fc1e0d5def8f0d10",
"manifest.go": "c6b6fbdd5cc6963345048aa1b0a32f61b7b0b5b84616a15827aea290bd3758e1",
"manifest_extractedby_test.go": "8019fe35675a1e801b7bda829ee1d647de32db03c029b1c651f71573e644c69e",
"manifest_retry_test.go": "e6b4a321ccddfeed8cdbfabe6d031d5a9c59a33b47baf0b6d21150fa8406748a",
"multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66",
"probe_real_manual_test.go": "6170cd7d41b6e099ad93e229f55e5f9d64d509412969f9bf162788ae8e787aef",
"probe_workersai.go": "4667c7edeb75a7f69d99bdb9141c8d55325d3b99aecc9a661a4080c91ef517fd",
"progress.go": "c2c86ecfea5a8227eb40018c55d48f26c4f2de1b8775fde9acd231cba8b7bb37",
"probe_workersai.go": "2d12d7e83f13a83f7dca0f4f6ea1ff0041db70b4f47d86f940c2fac80cab6980",
"progress.go": "98380d3c374570f3182200484b9b2b6a093d41531433b80eb78a92cf94dec69a",
"progress_test.go": "21258889ad7680da2d6cf8812accda7e6a1aca6c6fd604394ec4363c2ed902bb",
"progress_wiring_test.go": "d1d144269539825ef4a4d9ffca5c49b021cb27cecea7d280309f3f935460b3cd",
"quota.go": "034a2a682b66606e9ab531a251e5157109c9a66ddca6e3ffcecc8fcd10dc0aa3",
@@ -176,25 +182,27 @@
"repoguard_test.go": "ce0316a59af944c3cf391961653fe82294fd45e728d35b4b7fcd99b2b20e7b5c",
"safewrite.go": "d5dd09ffe894fb9fb94656a4bb50d246bd8c65ab21a7ad81463f43dfdacc8a57",
"safewrite_test.go": "2980f0f86f78fb265403e31d4cba6da43f4dbc16e7c4711cfccba23175578358",
"scan.go": "d26b74e46204697a573ed24ee427c06878349611fcaf3c35df295b826b690525",
"scan.go": "546329b883228687a60d48419c19113a6208f0f5ea18a0d3ba0fc401ab3c0974",
"scan_carry_lasterror_test.go": "6fa542422fb3e00b5ec4355b5db8df7086361b2dacefd67755d75c0ddfa55575",
"scan_dedup_scale_test.go": "573b3c78bec86614bf4d2f99b7c3c3157f92c24af73cae7290428fc7f43f0362",
"scan_dedup_test.go": "580ec58596ca54aa3d29b417e7d098fd2e3455fd39bf10e7da02d453c0786df0",
"scan_skipped_test.go": "983ecabb5d93388a501836d15dd445789b7f3028a7f3b966e04425ad4d5bfd9c",
"scan_test.go": "d0e472686ff41da2421bfd45b63099fd75877e6fc72f26dfdd1a07dc64c9f0cd",
"sourcerepair.go": "2ac80c82ca0cb376515448ecc4c38ab542cc8c885f3cc5bc8b604a94c064c7f3",
"sourcerepair.go": "bdb1a4b64d028a9df42a7b9258a431e01f1be8871c490b92011d0c6ee5e53b79",
"sourcerepair_live_test.go": "2a7f5d6fada420a7f55fa52b87361842755c68c1cb58f8c3fd0153341f709a0a",
"sourcerepair_test.go": "2a1ddddaad8ac7adc36350859e2d742c0fea12b99370a4d4831d390db1cc6720",
"stallguard.go": "13be8ae905492054d516f466856bc6949b16e1af6ea32068f94b21635799bdc6",
"stallguard_test.go": "678b82a8448d1394a0a85d4af1f133c980f5ff0dcb54953b0fcf1671d186147f",
"supervisor/hidewindow_other.go": "291b4ecb0783ff3e813c68b5464161d9af2f84d31c773c7f1f710f218bb5d610",
"supervisor/hidewindow_windows.go": "29796dbe90ecb35e6f1451dacf956d42928c6a393271befc5c7f6863d5315fb3",
"supervisor/supervisor.go": "cb17332ce5a7190627839e624895c56b508635edbd2c29758f794d5c3bb39388",
"supervisor/supervisor.go": "f79b32e92e67058a056dc749a6182573eefcbbc9bc7b2cd5a997f4e8066a004e",
"supervisor/supervisor_test.go": "f5582d54c55065fbc834fe96754df9c8cd6efe9f88f30208bbce84ddea08fea7",
"sync_status.go": "59e8e2c81ec5a1cbd46e852bd7c794c4179c76ac62561f21550a41afb9e2be2d",
"sync_status_test.go": "9314b354679f0c5090257cf6918185c33b74102bfac0acb655c3e97b81a247b3",
"sync_status.go": "799d3481c361647c9cf1ae40ac4c550d6690a87f843c514f8b830d811e7bba1f",
"sync_status_test.go": "73bbc83c9e8fc18575895a7b7b5feab7a76d41f05a74d74bbbdbf98b045ccfa2",
"t52_library_test.go": "14188d0afdad0f9aa16d19a74ea28c45c3d18fcfeabc5deb6fb0466d05778a51",
"template_install.go": "d0aa0098e431696a63ef8d3714c124591b8ba193ccd5a8359a4ea03e397ee358",
"template_install_test.go": "e2d096a80c52ab10fd1a9e26cab2a9621cc2017df8c0c5726aaa450a00375566",
"template_not_knowledge_test.go": "d25e84a13fb97351cd3a579178a5e24cb266309bdc9d526ff7bec442071df3ab",
"template_not_knowledge_test.go": "59c272068d308defd1fb19aedfc5b311bb64d2fc58e958e78b98d9f7ba27b6f1",
"templatefs/.claude/VERSION": "04d659518a1793d5396007fcabdc9211a8c1db8406bf4e0fa8069171fdb06518",
"templatefs/.claude/commands/issue-handle.md": "2010f4e418dbb51e928873019c30602b79ff4dbf79509cb412884616f10a0e01",
"templatefs/.claude/commands/rag-extract-file.md": "33e8f10fbde076452a5a304cb153982603b238ffe1805e0faf7b7fbed246298e",
@@ -232,6 +240,7 @@
"templatefs/system-dev/wiki/status.md": "ef03d670e48847c94954db93469bcb9d0bf38860a155fb0847c3e16d3411b839",
"templatefs/system-dev/workflows/tasks-project-sync.local.sh": "7a611abc8b597d7c047e1b73c4a19c011ba1719f6688351b677b4feee0a4d4fe",
"templatefs/system-dev/workflows/tasks-project-sync.yaml": "e8c7dd78d338e64219ad0eb7aae27aec02b6ac2ad962f309145055a87f1cd5db",
"templateuntouched.go": "74e6c0dda22228ac65cc841ce83460d81026134d41b4b16c528bf7ce760db26f",
"testdata/grounding/card-access-paraphrase.md": "2bbf4427b1ea2c5b73995b135752f60c4978f2c9121647c4a0aa7b42d6063570",
"testdata/grounding/card-delivery-paraphrase.md": "727f6d09cb4ebb0e8248988bb9872573d4339f2256e6723a588eceb04aa4fd42",
"testdata/grounding/card-geek6688-faithful.md": "7b7200aa2d142720cde51c45b7266e32a5b9f278a808a115c2546443903f09f8",
@@ -239,8 +248,9 @@
"testdata/grounding/source-access-en.md": "a1c28715a06940b0bb512cd2220aa6431bf47c1ae84da314f4a076ed6d21c906",
"testdata/grounding/source-delivery-zh.md": "80732bee124312b791609fe499b274f81cdaa12916b30fc21bc309c41ad1ac86",
"testdata/grounding/source-requirements.md": "5fd1115db5009aee98667634a9a374ee66460a2cb53c3819065080f2f47506f3",
"tidy.go": "e0b104866a11d0ce25ac80ca536a644900907d1ec6231f95e132843d88be5d0a",
"tidy.go": "aee6d6caebe45721d714f2954a45e453a3521b44d7edafe1b7bd15ece6d98a47",
"tidy_test.go": "80fd37d7abf9f9fd075006da42aea73a3843c49f969e62623687519102dfe3ea",
"tidy_wiki_untouched_test.go": "4a42be32c770abdf9d29ee3489a1421447cac82557a4dafcf2f23e1cadc1d0c5",
"trigger.go": "f689f701bef08401f5d47f5b3d783d24a88fc9a9426fe41bdbbb3f9b152ab405",
"trigger_test.go": "0c2482d18cea0568cd2a0f5eae5b02a0984b9b2bc83461eb882c630d6381ca02",
"triggeroutcome.go": "25832326ecb3694d8a3a0f8a115ce8df6327b70ff61b0bec1357d57e53e81836",
+7 -1
View File
@@ -12,5 +12,11 @@
"0.18.39": "316426f50e886003",
"0.18.40": "a4485965c128ef9d",
"0.18.41": "8033de0cdec3537b",
"0.18.42": "43e0c6ac08233f07"
"0.18.42": "43e0c6ac08233f07",
"0.18.43": "34f17e1e6a190eaa",
"0.18.44": "19e26497ae609682",
"0.18.45": "dc05513103f93306",
"0.18.46": "1325784830f592ff",
"0.18.47": "0decb0810da46a3f",
"0.18.48": "a440a58a629f960c"
}
+28 -2
View File
@@ -122,6 +122,10 @@ type syncStatus struct {
// arcrun-rag#140:雲端上找不到、正在自動補送的資料夾(key=資料夾路徑)。
// 同上:形狀定義在 collector/sync_status.go,這裡原樣接住。
Resync map[string]collector.ResyncStatus `json:"resync,omitempty"`
// arcrun-rag#159:逐個看守資料夾的同步現況(key=資料夾路徑)。
// 同上:形狀定義在 collector/progress.gosync_status.go,這裡原樣接住。
// 這是資料夾那一列打不打勾的**唯一**依據(見 folder_badge.go)。
FolderProgress map[string]collector.SyncProgress `json:"folder_progress,omitempty"`
}
type skippedDoc struct {
@@ -247,7 +251,20 @@ type UIFolder struct {
// arcrun-rag#140:雲端上找不到先前送過的檔案(知識庫被重裝/清空過),正在自動補送。
// 🔴 這張票的病有一半是「**沒有任何地方會說話**」——檔案在資料夾裡、AI 卻查不到,
// 而且查不出為什麼。所以補送不能靜悄悄地跑,這一行就是那句話的落點。
ResyncNote string `json:"resyncNote,omitempty"` // 一句人話(沒事=空字串,前端不畫)
//
// ⚠️ 2026-08-28arcrun-rag#159**這一行不再畫在畫面上**——leo:「補送中是什麼
// 意思?不要發明奇怪狀態」。同步狀態改由下面 Sync/SyncTip 那一格的圖示表達,
// 而它算的是真的份數(見 folder_badge.go)。這個欄位留著給診斷檔與 status.json
// #140 的「不能靜悄悄地重送」由圖示接手:補送中的檔在 manifest 裡就是還沒送成功,
// 圖示本來就不會打勾。
ResyncNote string `json:"resyncNote,omitempty"`
// arcrun-rag#159:這一列的同步狀態圖示。Sync 是機器代碼(okworkingtrouble
// unknown),SyncTip 是滑過去看得到的一句短話。判準與文案都在 folder_badge.go
// ——**寫在 Go 這一側**,因為「打勾是不是真的」要能被測試守著,
// 而前端只負責把代碼換成一個圖示。
Sync string `json:"sync"`
SyncTip string `json:"syncTip"`
}
type UIAccount struct {
Name string `json:"name"`
@@ -257,7 +274,11 @@ type UIAccount struct {
// 連結」)——一個使用者可能連著不只一個知識庫,之前只有小幫手自己的版本會提示更新,
// 每個知識庫各自的雲端版本完全沒有畫面。判準與 portal 版本卡同一套
// collector.EvalCloudUpdate,不是 t103 的相容底線),這裡只翻成人話,不重新判斷。
CloudVerKnown bool `json:"cloudVerKnown"` // false查不到(連不上/還沒查過),前端要老實說「查不到」
CloudVerKnown bool `json:"cloudVerKnown"` // false**從來沒查到過**這台的版本(不是「這一輪連不上」,見 direct.go #159
// arcrun-rag#159:這一輪 /health 通不通。與 CloudVerKnown 是兩件事——
// 版本是事實(查到過就一直知道),可達性是當下狀態(會抽風)。
// 前端拿它把版本號調淡並在 tooltip 說明,**不拿它把版本抹掉**。
CloudVerFresh bool `json:"cloudVerFresh"`
CloudVerStale bool `json:"cloudVerStale"` // true=有新版可更新
CloudVerMine string `json:"cloudVerMine,omitempty"` // 這個知識庫目前的版本(可能連 Known=false 時也有值)
CloudVerLatest string `json:"cloudVerLatest,omitempty"` // 已知的最新版
@@ -518,6 +539,10 @@ func (a *App) GetState() UIState {
if rs, ok := sync.Resync[f]; ok {
uf.ResyncNote = rs.Note
}
// #159:這一列打不打勾。key 與 collector 寫入時同一把(資料夾絕對路徑)。
// 查不到=collector 還沒回報過這個資料夾 ⇒ folderBadge 回 unknown,不猜。
fp, known := sync.FolderProgress[f]
uf.Sync, uf.SyncTip = folderBadge(fp, known)
ui.Folders = append(ui.Folders, uf)
}
// 收回中的資料夾照樣列出來,只是標成「收回中」——不然按下移除之後它立刻消失,
@@ -533,6 +558,7 @@ func (a *App) GetState() UIState {
// t215per-account 雲端版本狀態——key 與 Host 同一套算法(shortHost),
// 對應 collector 寫入 status.json 時用的 instanceHostOf(兩者對一般 https URL 同值)。
if accSt, ok := sync.AccountDetails[ui.Host]; ok {
ui.CloudVerFresh = accSt.CloudCheckOK
ui.CloudVerKnown = accSt.CloudUpdateKnown
ui.CloudVerStale = accSt.CloudUpdateStale
ui.CloudVerMine = accSt.CloudVersion
+82
View File
@@ -0,0 +1,82 @@
// folder_badge.go — 資料夾那一列的**同步狀態圖示**`inkstone/arcrun-rag#159`)。
//
// leo 2026-08-28 原話:
//
// 「補送中是什麼意思?**不要發明奇怪狀態**⋯⋯他要知道的是『**我的資料夾是否同步了**』,
// **有同步打勾就好**」
// 「產生 GUI 就是要讓人減少讀字降低負擔,你在 GUI 寫這麼多字剛好違背它的原理」
//
// ── 這支檔存在的理由 ─────────────────────────────────────────────────────
// 在這之前,那一列的標籤是這樣算的(frontend/src/main.js:545):
//
// <span class="tag">${f.resyncNote ? '補送中' : '自動同步中'}</span>
//
// 也就是說它量的**不是同步狀態**,是「#140 那句補送說明是不是空字串」。
// 於是 2026-08-28 leo 的畫面上七列有六列寫「補送中」——連 `youlinhsieh-test1`
// (實測 `pending=0`、`repaired=2`,補送早就做完了)也照樣標補送中。
// ⇒ **標籤跟事實脫鉤**,而不是措辭不好聽。
//
// 現在改成從 `collector.SyncProgress` 算——那是 `(*Manifest).Progress()` 的原件,
// 與首頁大數字、診斷檔用的是同一個函式。**畫面上打的勾,跟首頁的數字同源。**
//
// ── 三種狀態,沒有第四種 ─────────────────────────────────────────────────
//
// ✅ ok Done == Total 每一份我認得的檔都送進知識庫了,而且送上去之後沒再改過
// 🔄 working 還沒送完,但沒有出錯的跡象
// ⚠️ trouble 已經放棄自動重試(Stuck),或**一份都還沒成功而且每一份都失敗過**
//
// 🔴 第三種的第二個條件是 leo 那句「Geek6688 很久沒碰了也顯示補送中?」逼出來的。
// 實查他機器上的 manifest2026-08-28):`geek6688-test1` 的 14 份**每一份的
// fail_count 都大於 0**7 與 3),錯誤是雲端回 `HTTP 500 Node post_block failed:
// Too many subrequests``done=0`。
// ⇒ 這不是「排隊中」,是「一直在撞牆」。標成同步中=叫他等一件正在壞掉的事。
// ⇒ 但**單看「有檔案在重試」不夠**:`pms` 有 110 份待處理、其中只有 4 份在重試、
// 已經送成功 23 份——那是健康的積壓,標警告就變成新的噪音(實測 9 個資料夾裡
// 會有 7 個亮警告,等於回到 leo 抱怨的「滿畫面都是狀態」)。
// **所以第二個條件要求 `Done == 0`:一份都沒成功、而且全都在失敗,才叫撞牆。**
//
// 🔴 剛加進來、還沒開始送的資料夾(Failing == 0)落在 working,不會誤報警告。
package main
import (
"fmt"
collector "arcrun-rag/collector"
)
// 資料夾狀態的機器代碼。前端只認這三個字串,圖示與顏色由 CSS 決定
// ——狀態名不進畫面,畫面上只有一個圖示(GUI 的目的是讓人少讀字)。
const (
folderSyncOK = "ok"
folderSyncWorking = "working"
folderSyncTrouble = "trouble"
// folderSyncUnknowncollector 還沒回報過這個資料夾(剛加進來、第一輪還沒跑完)。
// **不能落到 ok**——那會是「還沒查就打勾」,正是這張票的紅線。
folderSyncUnknown = "unknown"
)
// folderBadge 把一個資料夾的同步現況翻成「一個狀態代碼+一句短提示」。
//
// knownstatus.json 裡有沒有這個資料夾的紀錄。沒有就是 unknown,不猜。
//
// 提示句刻意短:它住在 tooltip 裡,而 leo 的紅線是「**不要把長句子搬進 tooltip
// 裡繼續長**」。要看細節的人有診斷檔(疑難排解那張卡),不是靠這一行。
func folderBadge(p collector.SyncProgress, known bool) (state, tip string) {
if !known {
return folderSyncUnknown, "還在確認這個資料夾"
}
switch {
case p.Total == 0:
// 資料夾是空的、或裡面沒有我讀得了的檔。**不是「同步好了」**,
// 所以不打勾——打勾要對應「東西真的在知識庫裡」。
return folderSyncUnknown, "還沒有可整理的檔案"
case p.Done == p.Total:
return folderSyncOK, fmt.Sprintf("已同步 · %d 份", p.Done)
case p.Stuck > 0:
return folderSyncTrouble, fmt.Sprintf("%d 份一直送不上去", p.Stuck)
case p.Done == 0 && p.Failing > 0:
return folderSyncTrouble, fmt.Sprintf("%d 份都還沒送成功", p.Failing)
default:
return folderSyncWorking, fmt.Sprintf("同步中 · 還有 %d 份", p.Total-p.Done)
}
}
@@ -0,0 +1,78 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"testing"
collector "arcrun-rag/collector"
)
// TestDumpBadgeFixture 拿**這台機器上真的 status.json**跑一次 folderBadge
// 把結果印出來/落成畫面驗收要用的 fixture`inkstone/arcrun-rag#159`)。
//
// 🔴 為什麼不用手寫 fixture:驗收要貼的是「改後的實際畫面」,而畫面上的圖示
// 必須是**真的那個函式**算出來的。手抄一份 fixture 就是第二套實作,
// 貼出來的截圖也就不再是證據。
//
// 預設跳過(要真機資料才有意義):`DUMP_BADGE_FIXTURE=1 go test -run TestDumpBadgeFixture`
func TestDumpBadgeFixture(t *testing.T) {
if os.Getenv("DUMP_BADGE_FIXTURE") == "" {
t.Skip("設 DUMP_BADGE_FIXTURE=1 才跑(要讀這台機器真的 status.json")
}
home, _ := os.UserHomeDir()
dir := filepath.Join(home, ".arcrun-rag")
var st struct {
FolderProgress map[string]collector.SyncProgress `json:"folder_progress"`
Resync map[string]collector.ResyncStatus `json:"resync"`
}
b, err := os.ReadFile(filepath.Join(dir, "status.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(b, &st); err != nil {
t.Fatal(err)
}
// status.json 還沒有 folder_progress(那台跑的是舊版)⇒ 從 manifest 現算,
// 用的仍是 (*Manifest).Progress() 本尊,不是另一套數法。
if len(st.FolderProgress) == 0 {
st.FolderProgress = map[string]collector.SyncProgress{}
ms, _ := filepath.Glob(filepath.Join(dir, "manifest-official-*.json"))
for _, mp := range ms {
m, err := collector.LoadManifest(mp, "")
if err != nil || m == nil || m.Root == "" {
continue
}
st.FolderProgress[m.Root] = m.Progress()
}
}
roots := make([]string, 0, len(st.FolderProgress))
for r := range st.FolderProgress {
roots = append(roots, r)
}
sort.Strings(roots)
out := []map[string]any{}
for _, r := range roots {
p := st.FolderProgress[r]
state, tip := folderBadge(p, true)
fmt.Printf("%-56s total=%-5d done=%-5d pending=%-5d stuck=%-3d failing=%-4d → %-8s %s\n",
filepath.Base(r), p.Total, p.Done, p.Pending, p.Stuck, p.Failing, state, tip)
out = append(out, map[string]any{
"path": r, "sync": state, "syncTip": tip,
"resyncNote": st.Resync[r].Note,
})
}
if dest := os.Getenv("BADGE_FIXTURE_OUT"); dest != "" {
j, _ := json.MarshalIndent(out, "", " ")
if err := os.WriteFile(dest, j, 0o644); err != nil {
t.Fatal(err)
}
t.Logf("fixture 寫到 %s", dest)
}
}
+110
View File
@@ -0,0 +1,110 @@
package main
import (
"strings"
"testing"
collector "arcrun-rag/collector"
)
// TestFolderBadge 守住「打勾是真的」這條紅線(`inkstone/arcrun-rag#159`)。
//
// leo 的紅線原文:「打勾要是**真的**:說得出『打勾』的判準是什麼,
// **不能是『沒有錯誤就打勾』**」。
// ⇒ 下面每一格都在問同一件事:**這個狀態對應得上 manifest 裡的哪個事實?**
func TestFolderBadge(t *testing.T) {
cases := []struct {
name string
p collector.SyncProgress
known bool
want string
}{
{
// 打勾的唯一判準:認得的檔**每一份**都送上去了,而且送上去之後沒再改過
// Done 的定義見 progress.goIngestedHash != "" && == ContentHash)。
name: "全部送完才打勾",
p: collector.SyncProgress{Total: 19, Done: 19},
known: true,
want: folderSyncOK,
},
{
name: "差一份就不准打勾",
p: collector.SyncProgress{Total: 19, Done: 18, Pending: 1},
known: true,
want: folderSyncWorking,
},
{
// 🔴 「沒有錯誤就打勾」的反例:沒有任何失敗,但也還沒送完。
name: "零錯誤但沒送完=同步中,不是打勾",
p: collector.SyncProgress{Total: 133, Done: 23, Pending: 110},
known: true,
want: folderSyncWorking,
},
{
// leo 2026-08-28 桌面實測值:geek6688-test1(14 份全在重試、一份都沒成功,
// 雲端回 HTTP 500 Too many subrequests)。這是「不再標成進行中」的那一格。
name: "一份都沒成功而且全都在重試=警告",
p: collector.SyncProgress{Total: 14, Done: 0, Pending: 14, Failing: 14},
known: true,
want: folderSyncTrouble,
},
{
// 對照組:健康的積壓不准亮警告,不然畫面又變回「滿滿都是狀態」。
// 實測值取自 pms(110 待處理裡只有 4 份在重試,已經成功 23 份)。
name: "有成功過的積壓=同步中,不亮警告",
p: collector.SyncProgress{Total: 133, Done: 23, Pending: 110, Failing: 4},
known: true,
want: folderSyncWorking,
},
{
// 剛加進來、還沒開始送:一份都沒成功,但也還沒失敗過 ⇒ 不准嚇人。
name: "剛加進來還沒送=同步中",
p: collector.SyncProgress{Total: 20, Done: 0, Pending: 20},
known: true,
want: folderSyncWorking,
},
{
// 已放棄自動重試=**不會自己好**,就算大多數都送成功了也要講。
name: "有放棄重試的就亮警告",
p: collector.SyncProgress{Total: 4184, Done: 817, Pending: 3342, Stuck: 25, Failing: 95},
known: true,
want: folderSyncTrouble,
},
{
// 空資料夾不准打勾——打勾要對應「東西真的在知識庫裡」。
name: "沒有可整理的檔案不打勾",
p: collector.SyncProgress{},
known: true,
want: folderSyncUnknown,
},
{
// 🔴 collector 還沒回報 ⇒ 零值。舊寫法會讓它看起來「什麼問題都沒有」,
// 在這裡它必須是 unknown,不能落到 ok。
name: "還沒回報過不准打勾",
p: collector.SyncProgress{},
known: false,
want: folderSyncUnknown,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, tip := folderBadge(c.p, c.known)
if got != c.want {
t.Fatalf("folderBadge(%+v, known=%v) = %q,預期 %q", c.p, c.known, got, c.want)
}
if strings.TrimSpace(tip) == "" {
t.Fatalf("每個狀態都要講得出自己是什麼(leo 驗收條件 3),但 tip 是空的")
}
// 紅線:提示句是**產品文案**,不准出現狀態碼/技術詞;也不准長成一段說明
// (leo:「不要把長句子搬進 tooltip 裡繼續長」)。
for _, bad := range []string{"HTTP", "500", "error", "token", "manifest", "pending", "stuck"} {
if strings.Contains(strings.ToLower(tip), strings.ToLower(bad)) {
t.Fatalf("提示句出現技術詞 %q%q", bad, tip)
}
}
if n := len([]rune(tip)); n > 20 {
t.Fatalf("提示句 %d 字,太長了(上限 20):%q", n, tip)
}
})
}
}
+112 -65
View File
@@ -166,25 +166,39 @@ function cardKbVersions(s) {
</div>`;
}
// kbVersionLine:單一知識庫的版本判定文案,首頁卡與各庫頁共用同一份翻譯,
// 不讓兩處各寫各的文字(那樣才真的會出現「兩處講不同的話」)。
// kbVersionLine:單一知識庫的版本,首頁卡與各庫頁共用同一份(不讓兩處各寫各的)。
//
// 三種情況都要照實講(cloud_version.go 記過的坑:把「查不到」呈現成「一切正常」):
// ① Known 且落後 → 紅字+按鈕,按下去帶 email 開 install 頁(同 portal 版本卡的預填做法)
// ② Known 且已最新 → 淡字「已是最新版」
// ③ 不 Known → 照原因分兩句:連不上這個知識庫/查得到目前版本但暫時查不到最新版
// 🔴 `inkstone/arcrun-rag#159`leo 2026-08-28):「**GUI 是國際通用,圖形指示,
// 不限語文**,所以任何出現文字都要謹慎,文字表示『我的設計不夠好所以要靠文字來補』」
// ⇒ 這一行以前是三句話(「有新版可更新(目前 x → 最新 y)」「已是最新版x
// 「目前連不上這個知識庫,查不到版本」)。現在只剩**版本號與圖示**:
//
// 1.4.60 ✅ 已是最新
// 1.4.46 → 1.4.58 ⬆️ 有新版(按鈕,按下去帶 email 開安裝頁)
// ○ 從來沒查到過這台的版本
//
// 說明都收進 title(滑過去才出現),版面上一個字都不佔。
//
// 🔴 「這一輪連不上」**不再抹掉版本號**(見 collector/direct.go 那段註解):
// 版本查到過就是事實,抽風的是網路。連不上時版本號調淡,滑過去講得出來。
function kbVersionLine(a) {
const dim = a.cloudVerFresh ? '' : ' dim';
const tip = a.cloudVerFresh ? '' : '上次查到的版本(現在暫時連不上)';
if (!a.cloudVerKnown) {
const detail = a.cloudVerMine
? `已知版本 ${esc(a.cloudVerMine)},暫時查不到最新版本(稍後會自動再查)`
: '目前連不上這個知識庫,查不到版本';
return `<span class="d">${detail}</span>`;
// 分得出「沒查到這台的版本」與「查到了但暫時不知道最新版是多少」——
// 前者連版本號都沒有,後者有版本號可以顯示。
if (!a.cloudVerMine) {
return `<span class="kbv unknown" role="img" title="還沒查到這個知識庫的版本">○</span>`;
}
return `<span class="kbv${dim}" title="${esc(tip || '暫時查不到最新版本,稍後自動再查')}">${esc(a.cloudVerMine)}</span>`;
}
if (a.cloudVerStale) {
return `<span class="warn">有新版可更新(目前 ${esc(a.cloudVerMine)} 最新 ${esc(a.cloudVerLatest)}</span>
<button class="ghost" data-updatekb="${esc(a.email || '')}">前往安裝頁更新</button>`;
return `<span class="kbv${dim}" title="${esc(tip)}">${esc(a.cloudVerMine)}${esc(a.cloudVerLatest)}</span>
<button class="ico" data-updatekb="${esc(a.email || '')}"
title="前往安裝頁更新這個知識庫" aria-label="前往安裝頁更新這個知識庫">⬆️</button>`;
}
return `<span class="d">已是最新版(${esc(a.cloudVerMine)}</span>`;
return `<span class="kbv${dim}" title="${esc(tip)}">${esc(a.cloudVerMine)}</span>
<span class="fstat ok" role="img" title="已是最新版" aria-label="已是最新版">✅</span>`;
}
// installURLFor:與 portal 版本卡同一個做法——落後才需要按,按下去帶 email 讓安裝頁
@@ -320,7 +334,18 @@ function cardSkipped(k) {
// datapath → 樹(undefined=還沒問、null=問過但小幫手還沒回報)
// open:path → 這個資料夾的樹展開了沒
// nodespath → { 節點路徑: 展開了沒 }(沒有紀錄=用預設,見 nodeIsOpen
const treeState = { data: {}, open: {}, nodes: {} };
// whypath → { 節點路徑: 這一列的「為什麼」被點開了沒 }(arcrun-rag#159
const treeState = { data: {}, open: {}, nodes: {}, why: {} };
// ── 資料夾那一列的狀態圖示(`inkstone/arcrun-rag#159`)────────────────────
//
// leo 2026-08-28:「補送中、資料夾結構、移除,**變成 3 個 emoticon 就好,
// 畫面上都是字壓力很大**」「他要知道的是『我的資料夾是否同步了』,**有同步打勾就好**」
//
// 🔴 這裡**只做代碼→圖示的對照**,一個判斷都不做。
// 「該不該打勾」住在 Go 那一側(cmd/arcrun-app/folder_badge.go,有測試守著)——
// 前端自己判斷就會變成第二套判準,遲早跟後端說的不一樣。
const SYNC_ICON = { ok: '✅', working: '🔄', trouble: '⚠️', unknown: '○' };
// 資料夾路徑當不了 DOM id(含空白、斜線、中文)⇒ 折成一個穩定的短碼。
function treeBoxId(path) {
@@ -390,13 +415,12 @@ function renderFolderTree(path) {
// (指定了空資料夾,它就該在畫面上存在),前者是「再等一下」。
// 兩者講同一句話,等於拿我們自己編的答案回答使用者。
if (tree === null) {
box.innerHTML = `<div class="ftmsg">同步小幫手還沒掃到這個資料夾的結構。
第一次同步跑完之後就會出現——檔案多的話要等久一點,按上面的「立刻同步」可以催它。</div>`;
box.innerHTML = `<div class="ftmsg">還沒掃到這個資料夾,第一次同步跑完就會出現。</div>`;
return;
}
const nodes = tree.nodes || [];
if (!nodes.length) {
box.innerHTML = `<div class="ftmsg">這個資料夾目前沒有任何內容(指定了就會在這裡,等你放東西進去)。</div>`;
box.innerHTML = `<div class="ftmsg">這個資料夾目前是空的。</div>`;
return;
}
const r = rollupTree(nodes);
@@ -421,25 +445,37 @@ function renderFolderTree(path) {
+ `<span class="tw">${kids.length ? '<i></i>' : ''}</span>`
+ `<span class="ic">${kids.length && open ? '📂' : '📁'}</span>`
+ `<span class="nm">${esc(n.name || '(未命名)')}</span>`;
if (n.skipped && n.total_files === 0) {
// 🔴 整棵沒走進去 ⇒ 不准顯示 0/0(那會是我們自己編的數字),改講理由。
const why = n.skip_reason || '(小幫手沒說明理由)';
row += `<span class="skip">整個資料夾未收</span>`
+ `<span class="why" title="${esc(why)}">${esc(why)}</span>`;
} else if (n.skipped) {
// #180:走進去了、數字是真的,只是這一層一個檔都沒收
// ⇒ **數字照顯示**(他要知道底下有幾個),理由也照講。
const why = n.skip_reason || '(小幫手沒說明理由)';
row += `<span class="num">${s.synced} / ${s.total}</span>`
+ `<span class="why" title="${esc(why)}">${esc(why)}</span>`;
} else {
const full = s.total > 0 && s.synced === s.total;
row += `<span class="num${full ? ' full' : ''}">${s.synced} / ${s.total}</span>`;
const why = gapWhy(s);
if (why) row += `<span class="why" title="${esc(why)}">${esc(why)}</span>`;
}
if (kids.length && !open) row += `<span class="why">…還有 ${kids.length} 個子資料夾</span>`;
// ── 一列只有:三角形 + 資料夾名 + `X / Y``inkstone/arcrun-rag#159`)──
//
// leo 2026-08-28:「**上下廢話刪除**,**產生 GUI 就是要讓人減少讀字降低負擔,
// 你在 GUI 寫這麼多字剛好違背它的原理**」——他的截圖上同一句
// 「這是安裝範本時鋪下來的空白樣板…」**在一棵樹裡出現了 5 次**。
//
// 🔴 **「收起來」不等於「刪掉」**(本票紅線):那些理由是使用者要救回被跳過的
// 資料夾時的線索(`inkstone/arcrun-rag#136`)。所以理由沒有被拿掉,
// 只是**不再預設佔畫面**——點那個數字就展開這一列自己的理由。
// 🔴 也**不塞進 tooltip**(同一條紅線:「不要把長句子搬進 tooltip 裡繼續長」),
// tooltip 只有「點一下看原因」這種操作提示。
let why = n.skipped
? (n.skip_reason || '(小幫手沒說明理由)')
: gapWhy(s);
// 收檔策略那句話(原本掛在樹的上方,leo 圈掉了)改掛在**根那一列**——
// 它講的就是這個監看根,點根的數字就看得到,資訊沒有消失。
if (n.parent === '-' && tree.reason) why = why ? `${tree.reason}${why}` : tree.reason;
// 整棵沒走進去(#180 之後:skipped 且 total_files 為 0)⇒ 不准顯示 0/0
// 那會是我們自己編的數字。用「—」表示「這個數字我沒有」。
const noCount = n.skipped && n.total_files === 0;
const full = !noCount && s.total > 0 && s.synced === s.total;
const shown = noCount ? '—' : `${s.synced} / ${s.total}`;
row += `<span class="num${full ? ' full' : ''}${why ? ' hasWhy' : ''}"`
+ (why
? ` data-twhy="${esc(n.path)}" data-twroot="${esc(path)}" role="button" tabindex="0" title="點一下看原因"`
: '')
+ `>${shown}</span>`;
html += row + `</div>`;
if (why && (treeState.why[path] || {})[n.path]) {
html += `<div class="ftwhy" style="padding-left:${indent + 21}px">${esc(why)}</div>`;
}
if (open) kids.forEach(emit);
}
(r.kids['-'] || []).forEach(emit);
@@ -448,17 +484,22 @@ function renderFolderTree(path) {
nodes.forEach((n) => { seen[n.path] = true; });
nodes.forEach((n) => { if (n.parent !== '-' && !seen[n.parent]) emit(n); });
// 🔴 樹的上方與下方**什麼都不留**`inkstone/arcrun-rag#159`leo 在截圖上圈了三處):
// ① 上方的「全部展開/全部收合 + 這是一般資料夾或筆記庫…」整條拿掉
// ② 下方的「數字是『已同步 / 這個資料夾底下總共』…」整段拿掉
// ③ 下方的補送說明拿掉(狀態改由資料夾那一列的圖示表達)
// leo 原話:「**上下廢話刪除,只留乾淨 Tree**」。
//
// 收檔策略那句話(tree.reason)沒有消失——它跟每一列的理由走同一條路:
// 點根那一列的數字就看得到(emit() 裡的 hasWhy)。
//
// **唯一的例外是截斷警告**:它只在樹真的不完整時才出現,而不講就是讓畫面
// 宣稱「這是全部」——那是說謊,不是廢話。
let foot = '';
if (tree.truncated) {
foot += `<div class="ftmsg">資料夾太多,只顯示前 ${nodes.length} 個(實際有 ${tree.total_nodes} 個)。</div>`;
foot = `<div class="ftmsg">只顯示前 ${nodes.length}資料夾(實際有 ${tree.total_nodes} 個)。</div>`;
}
foot += `<div class="ftmsg">數字是「已同步 / 這個資料夾底下總共」。兩者不相等是正常的——
差額是還讀不了的格式、不在收檔範圍的檔案(程式碼等),或還在處理中。</div>`;
box.innerHTML = `<div class="fthead">`
+ `<button class="ghost" data-texpand="${esc(path)}">全部展開</button>`
+ `<button class="ghost" data-tcollapse="${esc(path)}">全部收合</button>`
+ (tree.reason ? `<span class="why">${esc(tree.reason)}</span>` : '')
+ `</div><div class="ftbody">${html}</div>${foot}`;
box.innerHTML = `<div class="ftbody">${html}</div>${foot}`;
wireTree();
}
@@ -467,8 +508,13 @@ function renderFolderTree(path) {
async function toggleFolderTree(path) {
treeState.open[path] = !treeState.open[path];
const box = $(treeBoxId(path));
// #159:那顆按鈕現在只有一個 CSS 畫的三角形(沒有文字),轉向由 class 決定。
const btn = document.querySelector(`[data-tree="${cssq(path)}"]`);
if (btn) btn.textContent = (treeState.open[path] ? '▾ ' : '▸ ') + '資料夾結構';
if (btn) {
btn.setAttribute('aria-expanded', String(!!treeState.open[path]));
const row = btn.closest('.folder');
if (row) row.classList.toggle('open', !!treeState.open[path]);
}
if (!box) return;
box.style.display = treeState.open[path] ? '' : 'none';
if (!treeState.open[path]) return;
@@ -487,16 +533,6 @@ async function toggleFolderTree(path) {
// 屬性選擇器要跳脫引號——資料夾路徑什麼字元都可能有。
function cssq(s) { return String(s).replace(/["\\]/g, '\\$&'); }
function setAllTreeNodes(path, open) {
const tree = treeState.data[path];
if (!tree) return;
const st = {};
(tree.nodes || []).forEach((n) => { st[n.path] = open; });
if (!open) st[''] = true; // 全部收合時根自己留著,不然整棵消失、看起來像壞了
treeState.nodes[path] = st;
renderFolderTree(path);
}
function wireTree() {
document.querySelectorAll('[data-tnode]').forEach((el) => {
const toggle = () => {
@@ -510,11 +546,19 @@ function wireTree() {
el.onclick = toggle;
el.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } };
});
document.querySelectorAll('[data-texpand]').forEach((el) => {
el.onclick = () => setAllTreeNodes(el.dataset.texpand, true);
});
document.querySelectorAll('[data-tcollapse]').forEach((el) => {
el.onclick = () => setAllTreeNodes(el.dataset.tcollapse, false);
// #159:點數字=展開這一列自己的「為什麼」。理由沒有被刪掉,只是不再預設佔畫面。
// stopPropagation:這個數字常常長在「整列可點=展開子資料夾」的列上,
// 不擋住的話點原因會順便把子資料夾收掉。
document.querySelectorAll('[data-twhy]').forEach((el) => {
const toggle = (e) => {
e.stopPropagation();
const root = el.dataset.twroot, np = el.dataset.twhy;
const st = treeState.why[root] || (treeState.why[root] = {});
st[np] = !st[np];
renderFolderTree(root);
};
el.onclick = toggle;
el.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(e); } };
});
}
@@ -540,14 +584,17 @@ function pageLib(s, idx) {
: `正在從雲端收回…${f.retireRemaining ? `還有 ${f.retireRemaining}` : ''}`}</span>
</div>
${f.retireError ? `<div class="d folder-note">${esc(f.retireError)}</div>` : ''}` : `
<div class="folder">
<div class="folder${treeState.open[f.path] ? ' open' : ''}">
<button class="tw" data-tree="${esc(f.path)}" aria-expanded="${!!treeState.open[f.path]}"
title="展開這個資料夾" aria-label="展開或收合這個資料夾的內容"><i></i></button>
<span class="path" title="${esc(f.path)}">${esc(f.path)}</span>
<span class="tag">${f.resyncNote ? '補送中' : '自動同步中'}</span>
<button class="ghost" data-tree="${esc(f.path)}">${treeState.open[f.path] ? '' : '▸'} 資料夾結構</button>
<button class="ghost" data-rm="${esc(f.path)}" data-acc="${f.accIdx}">移除</button>
<span class="fstat ${esc(f.sync || 'unknown')}" role="img"
title="${esc(f.syncTip || '')}" aria-label="${esc(f.syncTip || '')}"
>${SYNC_ICON[f.sync] || SYNC_ICON.unknown}</span>
<button class="ico" data-rm="${esc(f.path)}" data-acc="${f.accIdx}"
title="移除這個資料夾" aria-label="移除這個資料夾並從知識庫收回">🗑</button>
</div>
<div class="ftbox" id="${treeBoxId(f.path)}"${treeState.open[f.path] ? '' : ' style="display:none"'}></div>
${f.resyncNote ? `<div class="d folder-note">${esc(f.resyncNote)}</div>` : ''}`).join('')
<div class="ftbox" id="${treeBoxId(f.path)}"${treeState.open[f.path] ? '' : ' style="display:none"'}></div>`).join('')
|| `<div class="empty"><div class="t">這個知識庫還沒有資料夾</div>
<div class="d">按右上的「加入資料夾」,選一個要自動整理的資料夾。</div></div>`}`;
}
+60 -12
View File
@@ -144,6 +144,41 @@ button.ghost:hover { color: var(--err); background: transparent; }
.folder .tag.retiring { color: rgba(var(--ink-rgb), .5); }
.folder-note { margin: -3px 0 9px; padding: 0 15px; font-size: 12.5px; }
/* ══ arcrun-rag#159:那一列只剩「三角形 + 路徑 + 兩個圖示」══════════════
leo 2026-08-28:「補送中、資料夾結構、移除,**變成 3 個 emoticon 就好,
畫面上都是字壓力很大**」「**展開三角形按鈕放資料夾上**」
⇒ 三角形移到列首(跟樹裡面的展開鈕同一個造型,使用者只要學一次)。 */
.folder > .tw {
flex: none; width: 22px; height: 22px; padding: 0; border: none; background: none;
display: inline-flex; align-items: center; justify-content: center;
}
.folder > .tw:hover { background: none; }
/* 三角形用 CSS 畫,不用 ▸▾ 字元——理由同樹裡那顆(見 .ftrow .tw i):
那兩個字在 Windows 的中文字型裡是全形,大小與位置每台機器都不一樣。 */
.folder > .tw i {
display: block; width: 0; height: 0;
border-left: 6px solid rgba(var(--ink-rgb), .5);
border-top: 5px solid transparent; border-bottom: 5px solid transparent;
transition: transform .12s;
}
.folder.open > .tw i { transform: rotate(90deg); }
.folder > .tw:hover i { border-left-color: var(--amber); }
/* 狀態圖示。**一個字都沒有**——說明住在 title/aria-label 裡,滑過去才出現。 */
.folder .fstat { flex: none; font-size: 14px; line-height: 1; cursor: default; }
/* 🔴 視覺層級要對得上意思:**只有需要你出手的那一格才准大聲**。
「同步中」是常態、不需要任何人做什麼 ⇒ 壓到跟灰階一樣安靜;
⚠️ 維持原色,因為它是唯一一個「你可能要看一下」的狀態。
(不壓的話 🔄 的藍色會比 ⚠️ 還跳,畫面就在對使用者喊錯的東西。) */
.folder .fstat.working { filter: grayscale(.85); opacity: .5; }
/* 還沒回報過的那一格刻意做得很淡:它不是「沒問題」,是「還不知道」。 */
.folder .fstat.unknown { color: rgba(var(--ink-rgb), .3); font-size: 12px; }
/* 圖示按鈕(移除):跟三角形同一個量級,hover 才變紅——不預設嚷嚷。 */
.folder .ico {
flex: none; padding: 4px 6px; border: none; background: none;
font-size: 14px; line-height: 1; filter: grayscale(1); opacity: .7;
}
.folder .ico:hover { background: none; filter: none; opacity: 1; }
.big-num { font-size: 26px; font-weight: 600; letter-spacing: .01em; }
.kv { display: flex; gap: 26px; margin-top: 4px; }
.kv .k { font-size: 12.5px; color: rgba(var(--ink-rgb), .45); }
@@ -299,6 +334,14 @@ input[type=text], input[type=password] {
.kbrow .nm { font-size: 14px; font-weight: 600; flex: none; }
.kbrow .right { display: flex; align-items: center; gap: 10px; font-size: 13px; }
.kbver { margin-bottom: 14px; font-size: 13.5px; display: flex; align-items: center; gap: 10px; }
/* arcrun-rag#159:版本那一格只有**數字與圖示**,說明收進 title。
等寬字讓 `1.4.46 → 1.4.58` 這種對照掃視時對得齊。 */
.kbv { font-family: ui-monospace, Menlo, monospace; font-size: 12.5px; color: rgba(var(--ink-rgb), .7); }
/* 這一輪連不上 ⇒ 版本號調淡表示「這是上次查到的」。**不是把它抹掉**——
版本查到過就是事實,抽風的是網路(見 collector/direct.go #159 那段)。 */
.kbv.dim { opacity: .45; }
/* 從來沒查到過:一個很淡的圈,跟資料夾那一列的 unknown 同一個語彙。 */
.kbv.unknown { font-family: inherit; color: rgba(var(--ink-rgb), .3); }
.warn { color: var(--err); font-size: 13px; }
#side .nav .dot.warn {
width: 7px; height: 7px; border-radius: 50%; flex: none;
@@ -432,12 +475,10 @@ input[type=text], input[type=password] {
margin: -3px 0 9px; padding: 8px 10px 6px;
background: var(--well); border-radius: 10px;
}
.fthead {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding-bottom: 7px; margin-bottom: 5px;
border-bottom: 1px solid rgba(var(--ink-rgb), .08);
}
.fthead button { padding: 4px 9px; font-size: 12px; }
/* 🔴 arcrun-rag#159`.fthead`(全部展開/全部收合+收檔策略那句話)**整條移除**。
leo 2026-08-28 在截圖上圈掉它:「上下廢話刪除,只留乾淨 Tree」
「產生 GUI 就是要讓人減少讀字降低負擔,你在 GUI 寫這麼多字剛好違背它的原理」。
收檔策略那句話沒有被刪掉——它改掛在根那一列的數字上,點一下就看得到。 */
/* 樹本體:檔案總管那種「緊湊但點得到」的密度。
overflow-x 讓很深的樹自己捲,不去撐破右側版面。 */
.ftbody { overflow-x: auto; padding: 1px 0; }
@@ -474,6 +515,19 @@ input[type=text], input[type=password] {
}
/* 全部同步完=這一格沒有待辦,用完整墨色讓它從灰階裡浮出來(不另發明「成功色」)。 */
.ftrow .num.full { color: var(--ink); }
/* #159:這一列有「為什麼」可以看時,數字自己就是那顆按鈕(畫面上不多一個字、
也不多一顆按鈕)。虛線底線是唯一的提示——**它不是裝飾,是可點的告示**。 */
.ftrow .num.hasWhy {
cursor: pointer;
border-bottom: 1px dotted rgba(var(--ink-rgb), .35);
}
.ftrow .num.hasWhy:hover { color: var(--ink); border-bottom-color: var(--amber); }
.ftrow .num.hasWhy:focus-visible { outline: 2px solid var(--amber); outline-offset: 1px; }
/* 點開之後那一行理由。預設不存在——這就是「收起來不等於刪掉」的落點。 */
.ftwhy {
font-size: 12px; line-height: 1.6; padding: 1px 4px 4px;
color: rgba(var(--ink-rgb), .55); white-space: normal; max-width: 46em;
}
/* 🔴 選擇器**不要**綁在 .ftrow 底下:頁首那句「為什麼只收這些」也用同一個 class,
綁死就只有樹裡面吃得到樣式、頁首那句會掉回 16px 的內文級——實撞過一次,
畫面上那句話比整棵樹都大聲,喧賓奪主。 */
@@ -484,12 +538,6 @@ input[type=text], input[type=password] {
font-size: 12px; color: rgba(var(--ink-rgb), .45);
flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis;
}
.fthead .why { flex: 1; min-width: 0; line-height: 1.6; }
/* 整棵沒走進去:語氣是告知不是錯誤,所以不用 --err。 */
.ftrow .skip {
font-size: 11.5px; color: rgba(var(--ink-rgb), .5);
border: 1px solid rgba(var(--ink-rgb), .18); border-radius: 5px; padding: 0 5px;
}
.ftmsg {
padding: 6px 2px 2px; font-size: 12px; line-height: 1.65;
color: rgba(var(--ink-rgb), .45);
+148 -20
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,8 +539,9 @@ 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)
}
gate.ok() // #153:回來了就把「連續逾時」的計數歸零——否則「連續」兩個字是假的
defer resp.Body.Close()
// 🔴 讀 64KB 而不是 1KB:觸發端點的回應是一層外殼包著工作流的輸出,
// 而**失敗的證據住在殼裡面**(見 triggeroutcome.go)。1KB 會把 JSON 切斷 ⇒
@@ -611,6 +633,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),
// 要在處理帳號之前就知道上一輪冷卻到什麼時候、今天已經算到幾份。
@@ -714,6 +742,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
folderTrees := map[string]FolderTree{}
var knownRoots []string
// `inkstone/arcrun-rag#159`:每個看守資料夾這一輪的同步現況(key=資料夾路徑)。
// 值是 (*Manifest).Progress() 的原件——與 totalProgress 同一個來源,只是沒有累加。
folderProgress := map[string]SyncProgress{}
// t210:跨帳號、跨資料夾累加的總量進度(見 rootProgress 註解)。
var totalProgress SyncProgress
var stuckReasons []string
@@ -740,8 +772,28 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
// t103per-account 雲端版本偵測
cloudVer, cloudOK := fetchCloudVersion(accCfg.CypherURL)
// 🔴 `inkstone/arcrun-rag#159`**一次探測失敗 ≠ 這台知識庫連不上。**
//
// leo 2026-08-28 的畫面上,`youlin.hsieh.dev` 那行紅字寫「目前連不上這個
// 知識庫,查不到版本」——而同一台機器上 curl 打同一支 /health 是 HTTP 200
// 亞秒回應。實查當下 status.json**三個帳號全部** `cloud_check_ok=false`、
// 錯誤都是 `dial tcp: ... no such host`,而剛開的 Go 行程(cgo 與純 Go
// 兩種 resolver 都試過)解得出來、GET 也 200。
// ⇒ 那是這台機器當下的 DNS 抽風,不是知識庫掛了。
//
// 舊寫法把「這一輪沒查到」直接呈現成「查不到版本」,於是**一次抽風就抹掉
// 我們早就知道的事實**。改成:查不到就沿用上一輪查到的版本
//(同 CarryForwardActivity 的理由),只有**從來沒查到過**才是真的不知道。
// CloudCheckOK 仍照實記這一輪的可達性——那是另一件事,不混在一起。
if strings.TrimSpace(cloudVer) == "" {
if prevAcc, ok := prevStatus.AccountDetails[accHost]; ok {
cloudVer = prevAcc.CloudVersion
}
}
// t215:這個帳號要不要更新——與 portal 版本卡同一套判準(見檔頭)。
cloudUpd := EvalCloudUpdate(cloudVer, cloudOK, latestRelease, latestOK)
// 第二個參數傳「我們知不知道它的版本」而不是「這一輪連不連得上」:
// 版本是**事實**,可達性是**當下狀態**,兩者不是同一個問題。
cloudUpd := EvalCloudUpdate(cloudVer, strings.TrimSpace(cloudVer) != "", latestRelease, latestOK)
accSt := AccountSyncStatus{
LastSync: time.Now().Format(time.RFC3339),
CloudVersion: cloudVer,
@@ -757,7 +809,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 != "" {
@@ -833,6 +885,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
skippedOtherNames = append(skippedOtherNames, p.SkippedOtherNames...)
// #104:這一根用了什麼策略、少收了什麼 —— 以前到這裡就被丟掉了
// (只有 CLI 的 stderr 講得出來,App 走的這條路一個字都不說)。
// #159:這一根的同步現況。與 folderPlans 同一個「掃成了才記」的閘——
// 掃壞的那輪 rootProgress 是零值,記下去畫面會把它讀成「0 份、已同步」
// =拿我們自己編的答案打勾。沒記的根由下面的沿用機制補上一輪的。
folderProgress[root] = rp.Progress
folderPlans[root] = FolderPlanStatus{
Mode: string(p.Plan.Mode),
Reason: p.Plan.Reason,
@@ -896,6 +952,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 迭代順序隨機,不排的話清單會自己跳動)。
@@ -922,6 +980,21 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
if len(folderPlans) > 0 {
st.FolderPlans = folderPlans
}
// #159:逐資料夾的同步現況。這一輪沒掃成的根**沿用上一輪**
// (同 MergeFolderTreeStore ② 的理由:畫面不該因為一輪掃壞就說「沒有狀態」);
// 已經不在看守清單上的根自然消失——folderProgress 的 key 只可能來自本輪的
// knownRoots,移除掉的資料夾不會被沿用回來。
for _, root := range knownRoots {
if _, ok := folderProgress[root]; ok {
continue
}
if prev, ok := prevStatus.FolderProgress[root]; ok {
folderProgress[root] = prev
}
}
if len(folderProgress) > 0 {
st.FolderProgress = folderProgress
}
st.SkippedDocCount = len(skippedSeen)
for _, sf := range skippedSeen {
st.SkippedDocs = append(st.SkippedDocs, sf)
@@ -969,8 +1042,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
// 使用者才看得到「為什麼」而不只是「幾份」。
// 2026-08-07:額度冷卻的 skip 訊息(quotaState.noticeNow().Combined()
// 用「會自動恢復」當識別字——同樣要讓使用者看得到原因,不是只看到「幾份」。
if strings.Contains(r.Error, "後重試") || strings.Contains(r.Error, "已暫停自動重試") ||
strings.Contains(r.Error, "會自動恢復") {
if explainsWhySkipped(r.Error) {
st.Failures = append(st.Failures, ExtractFail{
Path: r.Path,
Error: shortError(r.Error),
@@ -1109,11 +1181,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),
@@ -1447,6 +1525,29 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
now := runNow.Unix()
// 🔴 `inkstone/arcrun-rag#153` 第二輪:**樹算出來就立刻落地本機,排在所有雲端呼叫之前。**
//
// 為什麼非搬到這裡不可(2026-08-28 用 `ARCRUN_TRACE=1` 量出來的,不是推測):
// 對一個**健康**的雲端實例,每一發呼叫實測 24〜33 秒
//(資料夾總覽 26.3s/目錄索引 24.0・24.1・23.5s/送出一份筆記 33.2・44.6s)。
// 一個 12 層、10 個檔的資料夾,一輪就是十幾分鐘;leo 真正的設定是三個帳號、
// 好幾千個檔,一輪是好幾小時。**沒有任何一發超過 300 秒,所以逾時與斷路器都不會動**
// ——這不是卡住,是這一輪還沒輪到寫它。
//
// 而這棵樹是**純本機、秒級**算出來的(就在上面那次 Scan 的產物上),
// 它沒有理由排在十幾分鐘的雲端佇列後面。搬到這裡之後,使用者的資料夾結構
// 在開跑幾秒內就是對的,即使這一輪還要再跑一小時。
//
// 內容一個位元都沒變:BuildFolderTree 吃的是 payload.DirStatsm.Entries
// payload.AllExcludedDirsplan,而從 Scan() 到這裡之間**沒有任何東西動過 m.Entries**
//(動它的是下面「removed 暫時放回」那段,本來就在原位置之後)。
// 送上雲端那一發(syncFolderTree)**維持在原來的位置**,用的就是這一棵。
tree := BuildFolderTree(absRoot, cfg.libraryFor(absRoot), payload.DirStats, m.Entries,
payload.AllExcludedDirs, plan, runNow)
if !dryRun {
PublishFolderTreeNow(cfg.Manifest, root, tree, runNow)
}
// 結構先行(InkStoneCo#432026-08-15):掃描一結束(純本機、免費、秒級)就先把
// 「這個資料夾有哪些檔案/最近改了什麼」送上知識庫,**不等 LLM 萃取、不受額度影響**
// ——走 rag_ingest_card(零 LLM 的機械收口),所以刻意放在:
@@ -1482,8 +1583,6 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
// 再加第四個**只屬於它**的:**空資料夾一個事件都不會有**arcrun-rag#106 的情境本身),
// 所以它不能被任何「有事件才做」的閘擋住——分子分母都由現況算出,靜止時
// 內容雜湊自然擋住重送,不需要事件當第二道閘。
tree := BuildFolderTree(absRoot, cfg.libraryFor(absRoot), payload.DirStats, m.Entries,
payload.AllExcludedDirs, plan, runNow)
if treeRes := syncFolderTree(cfg, absRoot, m, tree, dryRun, runNow); treeRes != nil {
results = append(results, *treeRes)
if treeRes.Status != "planned" {
@@ -1567,6 +1666,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 +1730,22 @@ 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()
if xerr == nil {
xgate.ok()
}
xerr = xgate.record(xerr) // 只有「等到超時」會被記帳,其餘錯誤原樣往下走
case "gemma":
cards, xerr = ExtractWithGemma(cfg.GeminiAPIKey, cfg.LLMModel, absRoot, ev.Path, cardOrigin)
default:
@@ -1700,7 +1826,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 +1869,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 +1906,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 +2045,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
}
+138
View File
@@ -0,0 +1,138 @@
package collector
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
// TestFolderProgressLandsInStatus 守住 `inkstone/arcrun-rag#159` 的接線:
// **逐資料夾的同步現況真的會寫進 status.json**,而且是每個根各一份。
//
// 為什麼要有這一條:小幫手畫面上那個打勾完全靠這個欄位。它沒寫出來的話,
// 前端拿不到資料 ⇒ 每一列都會落到 unknown ⇒ 使用者又回到「看不出同步了沒」。
func TestFolderProgressLandsInStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
base := t.TempDir()
rootA := filepath.Join(base, "A")
rootB := filepath.Join(base, "B")
for _, d := range []string{rootA, rootB} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
_ = os.WriteFile(filepath.Join(rootA, "a1.md"), []byte("# A1"), 0o644)
_ = os.WriteFile(filepath.Join(rootA, "a2.md"), []byte("# A2"), 0o644)
_ = os.WriteFile(filepath.Join(rootB, "b1.md"), []byte("# B1"), 0o644)
cfg := &DirectConfig{
Manifest: filepath.Join(base, "manifest.json"),
Accounts: []AccountConfig{{CypherURL: srv.URL, Namespace: "ns", WatchFolders: []string{rootA, rootB}}},
MaxRemoved: DefaultMaxRemovedRatio,
}
RunDirectOnce(cfg, false)
st, err := LoadSyncStatus(StatusFilePath(cfg.Manifest))
if err != nil {
t.Fatalf("讀不到 status.json%v", err)
}
if len(st.FolderProgress) != 2 {
t.Fatalf("folder_progress 應該每個看守資料夾各一份(2),got %d:%+v",
len(st.FolderProgress), st.FolderProgress)
}
if got := st.FolderProgress[rootA].Total; got != 2 {
t.Errorf("A 的分母應為 2got %d", got)
}
if got := st.FolderProgress[rootB].Total; got != 1 {
t.Errorf("B 的分母應為 1got %d", got)
}
// 🔴 逐資料夾加起來要等於首頁那行總量——它們本來就是同一組數字被切開,
// 對不起來就代表有人另算了一套(那正是這張票要拔掉的病)。
var sum SyncProgress
for _, p := range st.FolderProgress {
sum = sum.Add(p)
}
if sum.Total != st.Progress.Total-st.Progress.Unreadable {
t.Errorf("逐資料夾合計 %d ≠ 首頁總量 %d(扣掉沒進 manifest 的 %d",
sum.Total, st.Progress.Total, st.Progress.Unreadable)
}
}
// TestCloudVersionSurvivesOneFailedProbe 守住票的追加條件 6
// **「連不上」的判準不能是「這一輪解析失敗」。**
//
// leo 2026-08-28 畫面上寫「目前連不上這個知識庫,查不到版本」,
// 而同一台機器 curl 打同一支 /health 是 HTTP 200——那是機器當下 DNS 抽風。
// 一次探測失敗不准抹掉我們已經知道的版本。
func TestCloudVersionSurvivesOneFailedProbe(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
base := t.TempDir()
root := filepath.Join(base, "R")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
_ = os.WriteFile(filepath.Join(root, "r.md"), []byte("# R"), 0o644)
cfg := &DirectConfig{
Manifest: filepath.Join(base, "manifest.json"),
Accounts: []AccountConfig{{CypherURL: srv.URL, Namespace: "ns", WatchFolders: []string{root}}},
MaxRemoved: DefaultMaxRemovedRatio,
}
orig := fetchCloudVersion
defer func() { fetchCloudVersion = orig }()
// 「最新版是多少」也要釘住,否則這條測試會依賴 install.arcrun.dev 通不通
// ——那樣測的就不是我們要測的東西了(而且 CI 離線就紅)。
origLatest := fetchLatestCloudReleaseRaw
fetchLatestCloudReleaseRaw = func() (string, bool) { return "1.4.58", true }
latestMu.Lock()
latestFetched = time.Time{} // 清掉節流快取,讓 stub 這一輪真的生效
latestMu.Unlock()
defer func() {
fetchLatestCloudReleaseRaw = origLatest
latestMu.Lock()
latestFetched = time.Time{}
latestMu.Unlock()
}()
// 第一輪:查得到版本。
fetchCloudVersion = func(string) (string, bool) { return "1.4.60", true }
RunDirectOnce(cfg, false)
// 第二輪:探測失敗(模擬 DNS 抽風)。
fetchCloudVersion = func(string) (string, bool) { return "", false }
RunDirectOnce(cfg, false)
st, err := LoadSyncStatus(StatusFilePath(cfg.Manifest))
if err != nil {
t.Fatal(err)
}
if len(st.AccountDetails) != 1 {
t.Fatalf("應該只有一個帳號,got %d", len(st.AccountDetails))
}
for host, acc := range st.AccountDetails {
if acc.CloudVersion != "1.4.60" {
t.Errorf("%s:一次探測失敗就把版本抹掉了(got %q,應沿用 1.4.60", host, acc.CloudVersion)
}
if !acc.CloudUpdateKnown {
t.Errorf("%s:版本明明還知道,卻標成「查不到」", host)
}
// 可達性是另一件事,要照實記——不准為了讓畫面好看就說連得上。
if acc.CloudCheckOK {
t.Errorf("%s:這一輪明明沒連上,cloud_check_ok 卻是 true", host)
}
}
}
+1 -1
View File
@@ -318,7 +318,7 @@ func syncFolderCards(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents,
continue
}
pace()
status, _, err := cfg.postJSON(cfg.triggerURL(wf), map[string]any{
status, _, err := cfg.postJSON(stepFolderCard, cfg.triggerURL(wf), map[string]any{
"page_name": c.Page,
"path": c.Path,
"card_content": c.Content,
+36 -1
View File
@@ -355,7 +355,7 @@ func syncFolderTree(cfg *DirectConfig, absRoot string, m *Manifest, tree FolderT
"sync_token": h,
"nodes": tree.Nodes,
}
status, _, err := cfg.postJSON(cfg.folderTreeURL(), body)
status, _, err := cfg.postJSON(stepFolderTree, cfg.folderTreeURL(), body)
res.HTTPStatus = status
if err != nil {
res.Status = "failed"
@@ -478,3 +478,38 @@ func MergeFolderTreeStore(prev FolderTreeStore, fresh map[string]FolderTree, kno
}
return out
}
// PublishFolderTreeNow 把**這一個根**剛算好的樹立刻寫進本機快照,不等整輪跑完。
//
// 🔴 為什麼要有它(`inkstone/arcrun-rag#153` 第二輪,2026-08-28 實測):
// 一輪同步是**一條線**走完的,而對一個健康的雲端實例,每一發呼叫實測要 24〜33 秒
// `ARCRUN_TRACE=1` 量的:資料夾總覽 26.3s、目錄索引 24.0/24.1/23.5s、
// 送出一份筆記 33.2/44.6s)。一個 12 層、10 個檔的資料夾,一輪就是**十幾分鐘**;
// leo 真正的設定是三個帳號、好幾千個檔,一輪是**好幾小時**。
//
// 而 `folder-trees.json` 從前**只在整輪的最後**才落地
// ⇒ 使用者看到的是「小幫手開著、沒有錯誤、什麼都不動,資料夾結構永遠停在上一版」。
// **那不是卡住,是這一輪還沒輪到寫它。**
//
// 樹本身是**純本機、秒級**算出來的(BuildFolderTree 在掃描一結束就有答案),
// 它沒有任何理由要排在十幾分鐘的雲端佇列後面等。
//
// 與收工時那次 SaveFolderTreeStore 的分工:
// - 這裡:只**更新這一個根**,不刪任何東西(還不知道整輪看守哪些根)。
// - 收工:跑完整的 MergeFolderTreeStore,該刪的(已不看守的根)在那時才刪。
// 兩次都是冪等的覆蓋,先寫一次不會讓收工那次結果不同。
//
// 失敗一律吞掉:快照是給畫面看的,寫不進去不該擋住同步本體。
func PublishFolderTreeNow(manifestPath, root string, tree FolderTree, now time.Time) {
if manifestPath == "" || root == "" || len(tree.Nodes) == 0 {
return // 沒算出東西就別覆蓋上一輪的好資料(同 MergeFolderTreeStore 規則②)
}
path := FolderTreeStorePath(manifestPath)
store, _ := LoadFolderTreeStore(path) // 讀不到=沒有上一輪,零值可用
if store.Trees == nil {
store.Trees = map[string]FolderTree{}
}
store.Trees[root] = tree
store.UpdatedAt = now.UTC().Format(time.RFC3339)
_ = SaveFolderTreeStore(path, store)
}
+187
View File
@@ -0,0 +1,187 @@
// foldertree_publish_test.go — 「畫面有沒有跟上」的網(`inkstone/arcrun-rag#153` 第二輪)。
//
// 🔴 這裡測的判準跟 stallguard_test.go **刻意不一樣**,因為第一輪測錯了東西:
// 那一輪的網問的是「**有沒有發出警告**」,而總管 2026-08-28 端到端實測打回來的正是
// 「閘在跑、警告會發,**但主流程仍然沒走下去**」。
//
// stallguard_test.go 端點**不回應**時 → 這一輪要跳過它、要有話說
// foldertree_publish_test 端點**回應但很慢**時 → 畫面要的東西**不准排在後面等**
//
// 為什麼第二種才是使用者真正撞到的(`ARCRUN_TRACE=1` 實測,健康的 youlin 實例):
// 每一發雲端呼叫 24〜33 秒,**沒有一發接近 300 秒** ⇒ 逾時不會觸發、斷路器不會跳、
// 播報也大多不會響(門檻 30 秒)。一輪就這樣安靜地跑十幾分鐘到幾小時,
// 而 `folder-trees.json` 從前**只在整輪的最後**才落地。
// 使用者看到的是「小幫手開著、沒有錯誤、資料夾結構永遠停在上一版」。
package collector
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
// slowButHealthyServer**會回應、只是慢**。這是本檔的主角,不是黑洞。
func slowButHealthyServer(t *testing.T, delay time.Duration, hits *int64) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(hits, 1)
select {
case <-time.After(delay):
case <-r.Context().Done():
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
t.Cleanup(srv.Close)
return srv
}
// 🔴 本票第二輪的核心:**雲端每一發都很慢(但都會回來)時,資料夾結構仍然要在
// 這一輪還在跑的時候就已經寫好**。
//
// 這條會抓到的回歸:把樹的落地搬回「整輪最後」——那時它會等到所有雲端呼叫跑完,
// 使用者的畫面就又回到「永遠停在上一版」。
func TestFolderTreeLandsWhileTheRoundIsStillRunning(t *testing.T) {
origFetch := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "", false }
defer func() { fetchCloudVersion = origFetch }()
// 每一發 400ms——遠低於任何逾時(所以斷路器不會跳,正是實測的形狀),
// 但夠多發就足以把一輪拖長。
var hits int64
slow := slowButHealthyServer(t, 400*time.Millisecond, &hits)
// 多層資料夾+多個檔:讓「雲端呼叫的總時間」明顯大於「算一棵樹的時間」。
root := t.TempDir()
for _, d := range []string{"a", "b", "c", "d", "e", "a/a1", "b/b1"} {
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, d, "n.md"), []byte("# n\n內容"), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(root, "top.md"), []byte("# top\n內容"), 0o644); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(t.TempDir(), "m.json")
treePath := FolderTreeStorePath(manifestPath)
cfg := &DirectConfig{
Manifest: manifestPath,
Accounts: []AccountConfig{{CypherURL: slow.URL, Namespace: "n", APIKey: "k",
WatchFolders: []string{root}}},
Library: "kb", MaxRemoved: DefaultMaxRemovedRatio,
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
ForceSync: true,
}
roundDone := make(chan time.Duration, 1)
start := time.Now()
go func() {
RunDirectOnce(cfg, false)
roundDone <- time.Since(start)
}()
// 一邊等這一輪跑,一邊盯著快照什麼時候出現。
var treeAt time.Duration
var roundAt time.Duration
poll := time.NewTicker(20 * time.Millisecond)
defer poll.Stop()
deadline := time.After(60 * time.Second)
watch:
for {
select {
case roundAt = <-roundDone:
break watch
case <-deadline:
t.Fatalf("🔴 60 秒內這一輪還沒跑完(雲端每一發只有 400ms,這不該發生)")
case <-poll.C:
if treeAt == 0 {
if _, err := os.Stat(treePath); err == nil {
treeAt = time.Since(start)
}
}
}
}
if treeAt == 0 {
t.Fatalf("🔴 整輪跑完(%v)之前,folder-trees.json 一次都沒出現過——"+
"畫面上的資料夾結構會一直停在上一版", roundAt)
}
t.Logf("樹落地於 %v/整輪跑完於 %v/雲端被打了 %d 次", treeAt, roundAt, atomic.LoadInt64(&hits))
// 🔴 判準:樹要在**這一輪還早**的時候就落地,不是跟著整輪一起結束。
// 用比例而不是絕對秒數——CI 機器快慢不同,但「排在雲端佇列前面」這個性質不變。
if treeAt > roundAt/2 {
t.Fatalf("🔴 樹落地於 %v,而整輪 %v——它還是排在雲端呼叫後面等。\n"+
"本票第二輪的病就是這個:不是卡住,是這一輪還沒輪到寫它。", treeAt, roundAt)
}
// 內容也要對:使用者看到的節點數要等於地端真的有幾個資料夾。
store, err := LoadFolderTreeStore(treePath)
if err != nil {
t.Fatalf("讀不回快照:%v", err)
}
tree, ok := store.Trees[root]
if !ok {
t.Fatalf("快照裡沒有這個根:%+v", store.Trees)
}
// 根 a b c d e a/a1 b/b1 8
if tree.TotalNodes != 8 || len(tree.Nodes) != 8 {
t.Fatalf("🔴 節點數不對:TotalNodes=%d len(Nodes)=%dwant 8(根+7 個子資料夾)",
tree.TotalNodes, len(tree.Nodes))
}
}
// 收工那一次的合併語意不准被「提早落地」破壞:
// 已經不看守的根,仍然要在整輪結束時被清掉(不然使用者移除了資料夾還看得到它)。
func TestEarlyPublishStillLetsTheFinalMergeDropUnwatchedRoots(t *testing.T) {
origFetch := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "", false }
defer func() { fetchCloudVersion = origFetch }()
var hits int64
srv := slowButHealthyServer(t, time.Millisecond, &hits)
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "n.md"), []byte("# n\n內容"), 0o644); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(t.TempDir(), "m.json")
treePath := FolderTreeStorePath(manifestPath)
// 先塞一棵「已經不看守」的舊樹進快照。
if err := SaveFolderTreeStore(treePath, FolderTreeStore{
UpdatedAt: "2026-08-01T00:00:00Z",
Trees: map[string]FolderTree{"/已經移除的資料夾": sampleTree("/已經移除的資料夾", 1, 1)},
}); err != nil {
t.Fatal(err)
}
cfg := &DirectConfig{
Manifest: manifestPath,
Accounts: []AccountConfig{{CypherURL: srv.URL, Namespace: "n", APIKey: "k",
WatchFolders: []string{root}}},
Library: "kb", MaxRemoved: DefaultMaxRemovedRatio,
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
ForceSync: true,
}
RunDirectOnce(cfg, false)
store, err := LoadFolderTreeStore(treePath)
if err != nil {
t.Fatal(err)
}
if _, still := store.Trees["/已經移除的資料夾"]; still {
t.Fatalf("🔴 已經不看守的根還留在快照裡——使用者移除完還看得到它:%+v", store.Trees)
}
if _, ok := store.Trees[root]; !ok {
t.Fatalf("正在看守的那棵反而不見了:%+v", store.Trees)
}
}
+1 -1
View File
@@ -274,7 +274,7 @@ func syncInventory(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents, dr
// 只帶 library ⇒ 兩台機器上同名的資料夾會生出一模一樣的鍵,後同步的那台會把
// 前一台的總覽卡蓋掉。這裡與逐檔卡走同一組欄位,不另開一種。
mach := cfg.machineIdentity()
status, _, err := cfg.postJSON(cfg.triggerURL(wf), map[string]any{
status, _, err := cfg.postJSON(stepInventory, cfg.triggerURL(wf), map[string]any{
"page_name": page,
"path": res.Path,
"card_content": card,
+39 -8
View File
@@ -23,6 +23,7 @@ package collector
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
@@ -31,6 +32,10 @@ import (
)
// probeHTTP 探測用短逾時:只是要知道 route 在不在,不等 LLM 生成。
//
// 🔴 `inkstone/arcrun-rag#153`:這把 20 秒是保險,真正的上限由呼叫端帶進來的
// context 決定。探測是**每輪、每個帳號的第一發**——它撞到不回應的端點時,
// 後面所有帳號都還沒開始,所以它也是最早能認出「這個帳號今天不回應」的位置。
var probeHTTP = &http.Client{Timeout: 20 * time.Second}
// CloudAIState 是「某個雲端實例的 Workers AI 萃取能力」探測結果。
@@ -47,25 +52,27 @@ type CloudAIState struct {
// ⇒ 算通。這樣探測不會真的燒 LLM 額度,也不會產生垃圾卡片。
// - 401 代表 route 在、只是金鑰不對 ⇒ route 存在,同樣算「雲端有這功能」,
// 金鑰問題由既有的連線流程去報,不混在這裡講。
func ProbeWorkersAI(cypherURL, apiKey string) CloudAIState {
func ProbeWorkersAI(ctx context.Context, cypherURL, apiKey string) (CloudAIState, error) {
base := strings.TrimSuffix(strings.TrimSpace(cypherURL), "/")
if base == "" {
return CloudAIState{Ready: false, Note: "還沒連上知識庫"}
return CloudAIState{Ready: false, Note: "還沒連上知識庫"}, nil
}
body, _ := json.Marshal(map[string]string{"page_name": "", "text": ""})
req, err := http.NewRequest(http.MethodPost, base+"/portal/daemon/extract", bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/portal/daemon/extract", bytes.NewReader(body))
if err != nil {
return CloudAIState{Ready: false, Note: "雲端 AI 狀態查不到"}
return CloudAIState{Ready: false, Note: "雲端 AI 狀態查不到"}, nil
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Arcrun-API-Key", strings.TrimSpace(apiKey))
resp, err := probeHTTP.Do(req)
if err != nil {
// #153:錯誤原件一起回去,呼叫端才分得出「等到超時」與「立刻連不上」——
// 前者要記進斷路器,後者不該(不然一次斷網就把帳號判成停機)。
// 連不上(離線/網路問題)≠ 雲端沒裝。講「查不到」而不是「還沒通」,
// 免得把網路問題誤報成「你沒更新」讓用戶白跑一趟。
return CloudAIState{Ready: false, Note: "連不上你的知識庫,雲端 AI 狀態查不到"}
return CloudAIState{Ready: false, Note: "連不上你的知識庫,雲端 AI 狀態查不到"}, err
}
defer resp.Body.Close()
@@ -74,14 +81,38 @@ func ProbeWorkersAI(cypherURL, apiKey string) CloudAIState {
return CloudAIState{
Ready: false,
Note: "雲端 AI 還沒通 ⇒ 你的知識庫是舊版,請到 portal 按「立即更新」重裝一次",
}
}, nil
case resp.StatusCode < 500:
// 200/400/401… 都代表這條 route 存在=雲端有這個功能。
return CloudAIState{Ready: true}
return CloudAIState{Ready: true}, nil
default:
return CloudAIState{
Ready: false,
Note: fmt.Sprintf("雲端 AI 暫時有狀況(HTTP %d),稍後會自動再試", resp.StatusCode),
}
}, nil
}
}
// probeWorkersAI=帶「等待閘」的探測(`inkstone/arcrun-rag#153`)。
//
// 為什麼這一發特別重要:它是每輪、每個帳號的**第一發**。撞到不回應的端點時,
// 後面的資料夾一件都還沒開始 ⇒ 這裡認出來,整個帳號的其餘工作就都省下來了。
// 反過來說,漏掉這一發的話,前面所有的閘都白裝——2026-08-28 第一版就是這樣:
// 斷路器跳了,可是一輪還是要 20 秒,因為那 20 秒全花在這一發上。
func (c *DirectConfig) probeWorkersAI() CloudAIState {
if note := c.unreachableNote(); note != "" {
return CloudAIState{Ready: false, Note: note}
}
gate := c.openGate(stepProbeAI)
state, err := ProbeWorkersAI(gate.ctx, c.CypherURL, c.APIKey)
gate.release()
if err == nil {
gate.ok()
}
if perr := gate.record(err); perr != nil {
// 等到超時 ⇒ 講的是「沒有回應」,不是「你的雲端沒裝好」。
// 把等待誤報成「你沒更新」會害使用者白跑一趟去按重裝。
return CloudAIState{Ready: false, Note: perr.Error()}
}
return state
}
+16
View File
@@ -37,6 +37,16 @@ type SyncProgress struct {
Pending int `json:"pending"` // 還沒送,會自動接著做(含退避等待中的)
Stuck int `json:"stuck"` // 連續失敗達上限、已暫停自動重試——**不會自己好**
Unreadable int `json:"unreadable"` // 格式讀不了,根本沒進 manifest(由呼叫端補進來)
// FailingPending 裡「最近一次已經送失敗過、正在退避等重試」的份數
// `inkstone/arcrun-rag#159`)。**它是 Pending 的子集合,不進不變式**——
// 加總永遠只算 Total == Done+Pending+Stuck+Unreadable。
//
// 🔴 為什麼要分出來:Pending 同時裝著兩種完全不同的處境——「還沒輪到」與
// 「一直送不上去」。混在一起時畫面只能講「同步中」,而 2026-08-28 leo 桌面上
// geek6688-test1 的 14 份**每一份都在失敗重試**(雲端回 HTTP 500 Too many
// subrequestsfail_count 是 7 與 3),講「同步中」等於叫他等一件正在壞掉的事。
// 分出來,畫面才判斷得出該不該示警(見 arcrun-app/folder_badge.go)。
Failing int `json:"failing"`
}
// Add 把另一個資料夾/帳號的進度累加進來——首頁與診斷檔講的都是總量。
@@ -47,6 +57,7 @@ func (p SyncProgress) Add(o SyncProgress) SyncProgress {
Pending: p.Pending + o.Pending,
Stuck: p.Stuck + o.Stuck,
Unreadable: p.Unreadable + o.Unreadable,
Failing: p.Failing + o.Failing,
}
}
@@ -69,6 +80,11 @@ func (m *Manifest) Progress() SyncProgress {
p.Stuck++
default:
p.Pending++
if e.FailCount > 0 {
// #159:還在排隊,但**已經失敗過**——退避等下一次重試。
// 與 Pending 並存(子集合),不影響上面的不變式。
p.Failing++
}
}
}
return p
+1 -1
View File
@@ -166,7 +166,7 @@ func repairCardSourceBlocks(cfg *DirectConfig, absRoot string, m *Manifest, dryR
// ① 先重推(零 LLM)。欄位與 direct.go 送新卡時逐欄一致,
// workflow 進門會先刪同 page_name+同 source_path 的舊 blocks ⇒ 取代不疊加。
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.CardIngestWF), map[string]any{
status, _, perr := cfg.postJSON(stepRepairOrigin, cfg.triggerURL(cfg.CardIngestWF), map[string]any{
"page_name": pageNameOf(libPath),
"path": libPath,
"card_content": docCard,
+411
View File
@@ -0,0 +1,411 @@
// stallguard.go — 一發等不到回覆的請求,不可以讓整台機器的同步停下來
// `inkstone/arcrun-rag#153`)。
//
// 一輪同步是**一條線**走完的:帳號 → 看守資料夾 → 一件件事。整條線上每一發
// HTTP 都是同步的,所以只要其中一發停在那裡不回來,它後面的所有帳號、所有
// 資料夾就一起停擺——而且是**安靜地**停擺:日誌只有開工那一行,畫面上的
// 資料夾結構永遠停在上一版,沒有任何錯誤訊息。
//
// 為什麼不是「把 300 秒調小」:那只改變**卡多久**,沒改變「一發卡住就全停」。
// 一個階段可以連續打二十發(見 sourcerepair.go 的 sourceRepairBatch),
// 每發都等到超時的話,60 秒的上限一樣會把一輪拖成二十分鐘。
//
// 這支檔把「等待」變成三件有邊界、會說話的事:
//
// ① **每一發自己的上限**——按「這一發實際上在做什麼」給(會同步跑 AI 萃取的
// 那一發本來就慢,機械收口的那些不該也享有五分鐘),而且用 context 帶進
// 請求裡,不是只靠 client 那一把套用全部的總閘。
// ② **同一個帳號連續等不到回覆 ⇒ 這一輪不再打它**(斷路器)。一發卡住的代價
// 從「整輪停擺」降成「這個帳號這一輪跳過」,其他帳號、其他資料夾照常跑完。
// ③ **等待要有話說**——超過 stallNoticeEvery 就往 stdout 播一句
// 「哪個帳號、哪件事、等了多久」,並在收工時寫進 status.json。
// 靜默的等待跟當掉對使用者是同一件事。
//
// 🔴 播報用 `phase:"waiting"`**不能沿用既有的兩種**supervisor 把任何
// 非 `start` 的 JSON 值都當成「一輪跑完了」(supervisor.go 的 decode 迴圈),
// 沿用會讓托盤在同步途中跳回「看守中」——正是 t191 修掉的那個病。
package collector
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"sync"
"time"
)
// callStep 描述「這一發網路呼叫在做什麼」。
//
// Name 會出現在日誌與畫面上,所以它是**產品文案**:講使用者認得的那件事,
// 不出現 workflow 名、狀態碼、模型名。
type callStep struct {
Name string
Budget time.Duration
}
// 🔴 **每一個數字都是量出來的,不是推出來的**(2026-08-28 第三輪的教訓)。
//
// 第一輪我用「零 LLM 的機械收口應該很快」這個**推論**給了 60 秒,結果實測分佈是:
//
// 送出一份筆記 33.2 39.9 42.4 43.2 43.3 43.5 43.6 44.2 44.6 44.6
// 45.5 53.1 53.3 53.6 54.5 54.8 57.3 ← 真實尾巴到 57 秒
// 60.0 60.0 60.0 ← **這三個是我自己的上限把它剪斷**,不是雲端的耗時
//
// 後果不是「多等一下」:那 2 個被剪斷的檔判成 failed,斷路器跟著跳,
// **後面 9 個檔全部被跳過**,而畫面對使用者說的是「知識庫現在沒有回應」
// ——它一路都在回應,只是慢。**我的推論把一台健康的機器講成壞掉的。**
//
// ⇒ 判準改成:**上限只用來擋「真的不回來」,不用來表達「我認為它該多快」。**
// 每一格都給實測最大值的數倍餘裕;要調它,先去量(`ARCRUN_TRACE=1`)。
const (
// writeBudget=任何會把知識寫進雲端的呼叫(收卡/直送萃取/萃取/修出處/下架/收回)。
// 實測最大 57.3 秒(送出一份筆記)⇒ 300 秒 ≈ 5 倍餘裕。
// 這也正是改版前 directHTTP 用的數字——事實證明它本來就沒選錯。
writeBudget = 300 * time.Second
// registerBudget=只寫登記簿、不產生知識的呼叫(資料夾總覽/目錄索引/資料夾結構)。
// 實測 1.8〜26.3 秒 ⇒ 180 秒 ≈ 7 倍餘裕。
registerBudget = 180 * time.Second
// probeBudget=「這台還活著嗎」的唯讀探問。實測 0.0〜1 秒。
// 它短是因為**它問的就是活著沒**,不是因為我覺得它該多快。
probeBudget = 20 * time.Second
)
var (
stepIngestDoc = callStep{"整理一份文件", writeBudget}
stepIngestCard = callStep{"送出一份筆記", writeBudget}
stepExtractDoc = callStep{"請雲端讀一份文件", writeBudget}
stepRepairOrigin = callStep{"更新舊筆記的原文位置", writeBudget}
stepTakedown = callStep{"把刪掉的檔案從雲端下架", writeBudget}
stepRetire = callStep{"收回這個資料夾在雲端的資料", writeBudget}
stepFolderTree = callStep{"回報資料夾結構", registerBudget}
stepInventory = callStep{"送出資料夾總覽", registerBudget}
stepFolderCard = callStep{"送出目錄索引", registerBudget}
// stepProbeAI=每輪每個帳號的第一發(探測雲端 AI 通了沒)。它同時是最早
// 能認出「這個帳號今天不回應」的位置——認出來,這個帳號其餘的工作就都省了。
stepProbeAI = callStep{"確認雲端 AI 可不可以用", probeBudget}
// stepCloudAudit=跟雲端核對「先前送過的檔案還在不在」。唯讀查詢,
// 一輪可能連打 cloudAuditBatch 發——正是「一發卡住的代價會被乘上批次大小」的例子。
stepCloudAudit = callStep{"跟雲端核對哪些檔案還在", probeBudget}
)
// stallNoticeEvery=等多久開口說一次「還在等」。變數而非常數:測試要把它調快。
var stallNoticeEvery = 30 * time.Second
// stallStrikesBeforeSkip=同一個帳號連續幾發等到超時,就這一輪不再打它。
//
// 為什麼是 2 而不是 1:一次偶發的逾時不該讓整個帳號這一輪停手。
// 為什麼不是更多:每多一次就是多等一個 Budget,而這正是本票要砍掉的成本。
var stallStrikesBeforeSkip = 2
// StalledCall=這一輪「等太久」的一件事,寫進 status.json 讓畫面講得出
// 「哪個帳號、哪件事、等了多久」。
type StalledCall struct {
Account string `json:"account"` // 知識庫網址的主機名(使用者在畫面上看得到的那個)
Step string `json:"step"` // 白話的「哪件事」
WaitedSec int `json:"waited_sec"` // 等了幾秒
Skipped bool `json:"skipped"` // 這一輪之後不再打這個帳號
Note string `json:"note,omitempty"` // 給使用者看的一句話
}
// accountStall=某個帳號在這一輪的「等待帳」。
type accountStall struct {
strikes int
answered bool // 這一輪它**成功回應過** ⇒ 不准說它「沒有回應」
skip string // 非空=這一輪不再打它,內容是給使用者看的理由
}
// roundGuard 一輪一份,掛在 DirectConfig 上。
// makeAccountSubConfig 的 `sub := *c` 會把這個**指標**一起帶過去
// ⇒ 同一輪所有帳號、所有資料夾寫進同一份紀錄,斷路器才跨得了資料夾。
type roundGuard struct {
mu sync.Mutex
accounts map[string]*accountStall
stalls []StalledCall
// announce=把「還在等」播出去。測試會換掉它(預設寫 stdout)。
announce func(StalledCall)
}
func newRoundGuard() *roundGuard {
return &roundGuard{accounts: map[string]*accountStall{}, announce: announceStall}
}
// stdoutMu 保護 stdout 上的「一個 JSON 值」不被另一條 goroutine 插進去切成兩半。
//
// 🔴 為什麼非有不可:播報是在**另一條 goroutine** 上跑的,而每輪結束那筆
// `phase:"done"` 可能有幾十 KBresults 逐筆展開)——超過管線的原子寫入大小之後,
// 兩邊就會交錯。而 supervisor 的 json.Decoder 一旦讀到壞掉的值就
// `io.Copy(io.Discard)` 跳出迴圈(supervisor.go)⇒ **那個行程接下來所有的
// start/done 都不會再被看到**,托盤從此停在錯的狀態。
// 機率很小,代價是整條狀態線靜默死掉——這種比例的東西要用鎖解決,不是賭。
var stdoutMu sync.Mutex
// printJSONLine 把一個值印成 stdout 上完整的一行(與播報共用同一把鎖)。
func printJSONLine(v any) {
line, err := json.MarshalIndent(v, "", " ")
if err != nil {
return
}
stdoutMu.Lock()
defer stdoutMu.Unlock()
_, _ = os.Stdout.Write(append(line, '\n'))
}
// announceStall 把一句「還在等」印成 stdout 上的一個 JSON 值。
// 一次寫完整一個值:supervisor 那邊是 json.Decoder 逐值解,寫一半會炸掉整條線。
func announceStall(s StalledCall) {
line, err := json.Marshal(struct {
At string `json:"at"`
Phase string `json:"phase"`
StalledCall
}{time.Now().Format(time.RFC3339), "waiting", s})
if err != nil {
return // 播不出去也不能擋住本體
}
stdoutMu.Lock()
defer stdoutMu.Unlock()
_, _ = os.Stdout.Write(append(line, '\n'))
}
// skipReason 回「這個帳號這一輪已經被判定沒有回應」的白話理由;空=照常打。
func (g *roundGuard) skipReason(host string) string {
if g == nil {
return ""
}
g.mu.Lock()
defer g.mu.Unlock()
if a := g.accounts[host]; a != nil {
return a.skip
}
return ""
}
// Stalls 回這一輪等太久的清單(順序=發生順序)。
func (g *roundGuard) Stalls() []StalledCall {
if g == nil {
return nil
}
g.mu.Lock()
defer g.mu.Unlock()
return append([]StalledCall(nil), g.stalls...)
}
// succeeded 記一發「成功回來了」。
//
// 🔴 沒有這個函式的話,`strikes` 只增不減 ⇒ **一輪裡任意兩發逾時就會跳閘**,
// 而訊息上寫的是「**連續** 2 件事都等不到回覆」——那兩個字就是假的。
// 2026-08-28 實撞:一輪成功送出 8 份筆記、中間兩發被我自己過短的上限剪斷,
// 斷路器就跳了,後面 9 個檔全被跳過。
func (g *roundGuard) succeeded(host string) {
if g == nil {
return
}
g.mu.Lock()
defer g.mu.Unlock()
a := g.accounts[host]
if a == nil {
a = &accountStall{}
g.accounts[host] = a
}
a.answered = true
if a.skip == "" {
a.strikes = 0 // 已經跳閘就不再回頭;沒跳閘的話,成功一次就重新計數
}
}
// strike 記一發「等到超時」,必要時讓這個帳號這一輪停手。回傳給使用者看的那句話。
func (g *roundGuard) strike(host string, step callStep, waited time.Duration) string {
g.mu.Lock()
a := g.accounts[host]
if a == nil {
a = &accountStall{}
g.accounts[host] = a
}
a.strikes++
// 🔴 **它這一輪回應過的話,就不准說它「沒有回應」。**
// 2026-08-28 實撞:youlin 一路都在回應(成功送出 8 份筆記),只是慢,
// 而畫面對使用者說「知識庫現在沒有回應」——那是把一台健康的機器講成壞掉的。
// 使用者會照著這句話去查網路、去重裝,全都白費。
how := "沒有回應"
if a.answered {
how = "回得太慢"
}
tripped := a.strikes >= stallStrikesBeforeSkip && a.skip == ""
if tripped {
a.skip = fmt.Sprintf(
"知識庫「%s」現在%s(連續 %d 件事都等不到回覆),這一輪先跳過它;"+
"其他資料夾照常同步,稍後會自動恢復。",
host, how, a.strikes)
}
// 🔴 結尾那句「稍後會自動恢復」不是修辭,它是 explainsWhySkipped 認得的識別字
// sync_status.go)。少了它,這些被跳過的檔會**連一句原因都沒有地從畫面消失**。
// 有測試守著(TestSkipMessagesAlwaysExplainThemselves),改措辭前先看那條。
note := fmt.Sprintf("「%s」等了 %d 秒,知識庫「%s」%s;這一輪先跳過,"+
"其他資料夾照常同步,稍後會自動恢復。",
step.Name, int(waited.Seconds()), host, how)
g.stalls = append(g.stalls, StalledCall{
Account: host, Step: step.Name, WaitedSec: int(waited.Seconds()),
Skipped: tripped, Note: note,
})
announce, skip := g.announce, a.skip
g.mu.Unlock()
if announce != nil {
announce(StalledCall{Account: host, Step: step.Name,
WaitedSec: int(waited.Seconds()), Skipped: tripped, Note: note})
}
// 🔴 把連線池裡那條可能已經死掉的連線丟掉。
// 實撞的形狀(2026-08-28):行程活著、CPU 0%、`lsof` 一條 TCP 都沒有,
// 而堆疊停在 HTTP/2 的 roundTrip——連線在作業系統那層已經沒了,
// 連線池裡的殼卻還在,後面每一發都會被指派到同一個殼上、一發一發地等到超時。
// 丟掉之後下一發會重新建立連線,而不是繼續排在一條死掉的連線後面。
directHTTP.CloseIdleConnections()
if skip != "" {
return skip
}
return note
}
// unreachableNote 回「這個帳號這一輪已經被判定沒有回應」的白話理由;空=照常打。
//
// 呼叫端用它在**打之前**就掉頭,而不是打下去等到超時再說——那正是「一發卡住
// 就整輪停擺」的成本來源:不掉頭的話,一個沒有回應的帳號會讓這一輪的每一件事
// 各自再等一個 Budget。
func (c *DirectConfig) unreachableNote() string {
return c.guard.skipReason(instanceHostOf(c.CypherURL))
}
// callGate=一發網路呼叫的閘。用法(三行,缺一不可):
//
// gate := cfg.openGate(stepXxx)
// defer gate.release() // 停掉播報、放掉 context
// if note := gate.blocked(); note != "" { … 這一輪不再打這個帳號 … }
// … 真的去打 …
// if err != nil { return gate.record(err) } // 逾時才會被記帳,其餘原樣回
type callGate struct {
g *roundGuard
host string
step callStep
started time.Time
ctx context.Context
cancel context.CancelFunc
stop chan struct{}
once sync.Once
skip string
// notice=多久播報一次。**在 openGate 就抄成自己的一份**,播報那條 goroutine
// 不再去讀套件層的變數——那條 goroutine 的生命週期比呼叫端長一點點,
// 讀共用變數就是一個真的資料競爭(-race 抓到的)。
notice time.Duration
}
// openGate 開一發呼叫的閘。guard 為 nil(測試直接呼叫低層函式)時仍回一個可用的
// 閘:有 context deadline、不記帳、不播報——「沒裝 guard」不該變成「沒有上限」。
func (c *DirectConfig) openGate(step callStep) *callGate {
host := instanceHostOf(c.CypherURL)
gate := &callGate{g: c.guard, host: host, step: step, started: time.Now(),
stop: make(chan struct{}), notice: stallNoticeEvery}
if c.guard != nil {
gate.skip = c.guard.skipReason(host)
}
gate.ctx, gate.cancel = context.WithTimeout(context.Background(), step.Budget)
if gate.skip != "" {
return gate // 已經跳閘:不必播報,呼叫端會立刻回頭
}
go gate.keepTalking()
return gate
}
// keepTalking 在等待期間每隔 stallNoticeEvery 播一句「還在等」。
// 這是本票驗收條件②:卡住的那一發要有話說,而不是靜默。
func (gate *callGate) keepTalking() {
if gate.g == nil {
return
}
t := time.NewTicker(gate.notice)
defer t.Stop()
for {
select {
case <-gate.stop:
return
case now := <-t.C:
waited := int(now.Sub(gate.started).Seconds())
gate.g.mu.Lock()
announce := gate.g.announce
gate.g.mu.Unlock()
if announce != nil {
announce(StalledCall{
Account: gate.host, Step: gate.step.Name, WaitedSec: waited,
Note: fmt.Sprintf("還在等知識庫「%s」回覆「%s」,已經等了 %d 秒。",
gate.host, gate.step.Name, waited),
})
}
}
}
}
// blocked 回「這一輪已經不打這個帳號了」的理由;空=可以打。
func (gate *callGate) blocked() string { return gate.skip }
// traceCalls=把每一發的「哪件事/打誰/花了多久」印到 stderr`ARCRUN_TRACE=1` 開)。
//
// 🔴 為什麼要留這個:2026-08-28 第一次修完之後,端到端症狀還在,而現場能拿到的只有
// 一張 SIGQUIT 堆疊——堆疊只說得出「此刻卡在哪一發」,**說不出「這一輪的時間花到哪去了」**。
// 兩個人為此各自推測了一輪。有這一行就不必推測:跑一次,時間分佈直接列出來。
var traceCalls = os.Getenv("ARCRUN_TRACE") != ""
func (gate *callGate) trace(outcome string) {
if !traceCalls {
return
}
fmt.Fprintf(os.Stderr, "[trace] %7.1fs %-22s %-8s %s\n",
time.Since(gate.started).Seconds(), gate.step.Name, outcome, gate.host)
}
// release 停掉播報並放掉 context。**一定要 defer**context 活到呼叫端讀完回應
// 之後才釋放,所以不能在讀 body 之前呼叫。
func (gate *callGate) release() {
gate.once.Do(func() { close(gate.stop); gate.trace("done") })
gate.cancel()
}
// ok 記一發成功(見 roundGuard.succeeded)。與 record 成對,兩者呼叫端都要顧到。
func (gate *callGate) ok() { gate.g.succeeded(gate.host) }
// record 記一發失敗。只有「等到超時」才進帳(連線被拒之類的錯是**很快**回來的,
// 不是本票要修的病,記進去只會讓正常的斷網把帳號誤判成沒有回應)。
// 回傳要交給呼叫端的錯誤:逾時換成白話,其餘原樣。
//
// 可以在 release 之後呼叫——它不碰 context。
func (gate *callGate) record(err error) error {
if err == nil || !isStallError(err) {
return err
}
waited := time.Since(gate.started)
if gate.g == nil {
return fmt.Errorf("「%s」等了 %d 秒,知識庫「%s」沒有回應;稍後會自動恢復。",
gate.step.Name, int(waited.Seconds()), gate.host)
}
return errors.New(gate.g.strike(gate.host, gate.step, waited))
}
// isStallError 回答「這個錯誤是**等到超時**嗎」。
//
// 三種都要認得:我們自己的 context deadline、client.Timeout 包出來的
// `*url.Error{Timeout:true}`、以及被 `%w` 包過好幾層之後的同一個東西
//(萃取那條路會加上「連不上你的知識庫:」再包一層)。
func isStallError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.DeadlineExceeded) {
return true
}
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
return true
}
return false
}
+427
View File
@@ -0,0 +1,427 @@
// stallguard_test.go — 「一發卡住就整輪停擺」這個形狀的網(`inkstone/arcrun-rag#153`)。
//
// 🔴 這裡測的**不是**「逾時設多久」。把 300 秒調小照樣過不了這幾條,因為病不在
// 那個數字上:一個階段可以連續打二十發,每發都等到超時的話,上限設多小都會把
// 一輪拖垮。這幾條驗的是**執行模型**:
//
// ① 一個帳號的端點不回應 ⇒ 另一個帳號、另一個資料夾照樣跑完一輪(驗收條件①)
// ② 卡住的那一發要有話說:哪個帳號、哪件事、等了多久(驗收條件②)
// ③ 一輪跑完 folder-trees.json 真的被重寫(驗收條件③)——這是使用者眼中
// 「畫面永遠停在上一版」的那一格
package collector
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// stallTestTimings 把等待相關的旋鈕調成測試尺度,回傳復原函式。
// 調的是「等多久算久」,不是「有沒有上限」——上限的存在本身才是被測的東西。
func stallTestTimings(t *testing.T, budget, notice time.Duration) {
t.Helper()
oldPlain, oldLLM := stepIngestCard, stepIngestDoc
oldRepair, oldTree, oldInv, oldCard := stepRepairOrigin, stepFolderTree, stepInventory, stepFolderCard
oldExtract, oldTakedown, oldRetire := stepExtractDoc, stepTakedown, stepRetire
oldProbe, oldAudit := stepProbeAI, stepCloudAudit
oldNotice := stallNoticeEvery
// 🔴 **每一個** step 都要調到——漏掉一個,那一個就是新的瓶頸。
// 這不是測試的細節,是本票的形狀本身:2026-08-28 第一版就是這樣,斷路器
// 明明跳了,一輪還是要 20 秒,因為漏掉了每輪第一發的探測。
for _, s := range []*callStep{
&stepIngestCard, &stepIngestDoc, &stepRepairOrigin, &stepFolderTree,
&stepInventory, &stepFolderCard, &stepExtractDoc, &stepTakedown, &stepRetire,
&stepProbeAI, &stepCloudAudit,
} {
s.Budget = budget
}
stallNoticeEvery = notice
t.Cleanup(func() {
stepIngestCard, stepIngestDoc = oldPlain, oldLLM
stepRepairOrigin, stepFolderTree, stepInventory, stepFolderCard = oldRepair, oldTree, oldInv, oldCard
stepExtractDoc, stepTakedown, stepRetire = oldExtract, oldTakedown, oldRetire
stepProbeAI, stepCloudAudit = oldProbe, oldAudit
stallNoticeEvery = oldNotice
})
}
// 一個永遠不回應的知識庫(把請求放進黑洞,直到測試結束)。
func blackHoleServer(t *testing.T) *httptest.Server {
t.Helper()
done := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-done:
case <-r.Context().Done(): // 呼叫端自己放棄了=正是我們要的行為
}
}))
t.Cleanup(func() { close(done); srv.Close() })
return srv
}
// 一個正常回應的知識庫,順便數它被打過幾次。
func countingServer(t *testing.T, hits *int) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*hits++
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
t.Cleanup(srv.Close)
return srv
}
// ① 一個帳號的端點不回應,另一個帳號照樣跑完一輪;而且整輪的時間有上限。
//
// 沒有這道閘之前的行為:第一個帳號的第一發 POST 停在那裡不回來,
// 第二個帳號一次都不會被碰到,folder-trees.json 也永遠不會被重寫。
func TestOneDeadAccountDoesNotStopTheRound(t *testing.T) {
stallTestTimings(t, 300*time.Millisecond, 50*time.Millisecond)
origFetch := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "", false }
defer func() { fetchCloudVersion = origFetch }()
dead := blackHoleServer(t)
liveHits := 0
live := countingServer(t, &liveHits)
deadRoot, liveRoot := t.TempDir(), t.TempDir()
for _, r := range []string{deadRoot, liveRoot} {
if err := os.WriteFile(filepath.Join(r, "note.md"), []byte("# 一份筆記\n內容"), 0o644); err != nil {
t.Fatal(err)
}
}
manifestPath := filepath.Join(t.TempDir(), "m.json")
cfg := &DirectConfig{
Manifest: manifestPath,
Accounts: []AccountConfig{
{CypherURL: dead.URL, Namespace: "dead", APIKey: "k", WatchFolders: []string{deadRoot}},
{CypherURL: live.URL, Namespace: "live", APIKey: "k", WatchFolders: []string{liveRoot}},
},
Library: "kb", MaxRemoved: DefaultMaxRemovedRatio,
// 萃取那條路不是這條測試的題目:這裡驗的是「不回應的端點會不會把整輪鎖住」。
// 空 Extractor=走原文直送(rag_ingest_direct),一樣是打同一批端點。
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
ForceSync: true,
}
start := time.Now()
results, _, _ := RunDirectOnce(cfg, false)
elapsed := time.Since(start)
// 🔴 核心:一輪要跑得完,而且時間跟「壞掉的那個帳號有幾件事要做」無關。
// 上限抓得寬(真實情境下每發 300ms × 斷路器 2 次 + 活著那邊的工作),
// 但遠小於「每一發都各等一次」的量級——沒有斷路器時這裡會是好幾秒起跳。
if elapsed > 5*time.Second {
t.Fatalf("🔴 一輪花了 %v——一發等不到回覆就把整輪拖住了,正是本票要修的形狀", elapsed)
}
// 活著的那個帳號真的被服務到了(不是「兩個都被跳過所以很快」)。
if liveHits == 0 {
t.Fatalf("🔴 活著的帳號一次都沒被打到——壞掉的那個把整輪吃光了\nresults=%+v", results)
}
// 斷路器要真的跳過:同一個帳號不該被逐檔、逐階段各等一次。
stalls := cfg.guard.Stalls()
if len(stalls) == 0 {
t.Fatalf("🔴 等了那麼久卻一句話都沒說——使用者會看到「開著、沒錯誤、不動」")
}
tripped := false
for _, s := range stalls {
if s.Skipped {
tripped = true
}
}
if !tripped {
t.Fatalf("🔴 沒有任何一筆標成「這一輪跳過這個帳號」,斷路器沒生效:%+v", stalls)
}
if len(stalls) > 4 {
t.Fatalf("🔴 等了 %d 次才停手——斷路器該在 %d 次就跳,不然一輪還是會被拖垮:%+v",
len(stalls), stallStrikesBeforeSkip, stalls)
}
}
// ② 卡住的那一發要講得出「哪個帳號、哪件事、等了多久」。
func TestStallSaysWhichAccountWhichStepHowLong(t *testing.T) {
stallTestTimings(t, 300*time.Millisecond, 50*time.Millisecond)
dead := blackHoleServer(t)
// 🔴 播報會從**兩條** goroutine 進來(等待期間那條、以及記帳時呼叫端那條),
// 所以收集它的地方自己要上鎖——這不是測試的潔癖,是 -race 抓出來的真實併發。
var mu sync.Mutex
var said []StalledCall
g := newRoundGuard()
g.announce = func(s StalledCall) { mu.Lock(); said = append(said, s); mu.Unlock() }
cfg := &DirectConfig{CypherURL: dead.URL, Namespace: "n", APIKey: "k", guard: g}
_, _, err := cfg.postJSON(stepRepairOrigin, dead.URL+"/x", map[string]any{"a": 1})
if err == nil {
t.Fatal("端點不回應卻回了 nil error")
}
// 播報:等待期間就要開口,不是等到最後才說。
mu.Lock()
saidCount := len(said)
mu.Unlock()
if saidCount == 0 {
t.Fatal("🔴 等待期間一句話都沒播——靜默的等待跟當掉對使用者是同一件事")
}
host := instanceHostOf(dead.URL)
mu.Lock()
snapshot := append([]StalledCall(nil), said...)
mu.Unlock()
for _, s := range snapshot {
if s.Account != host {
t.Fatalf("沒講是哪個帳號:%+vwant %s", s, host)
}
if s.Step != stepRepairOrigin.Name {
t.Fatalf("沒講是哪件事:%+vwant %s", s, stepRepairOrigin.Name)
}
}
// 交給呼叫端的錯誤是產品文案:講哪件事、等多久、他會怎樣,不出現狀態碼/內部名詞。
msg := err.Error()
if !strings.Contains(msg, stepRepairOrigin.Name) {
t.Fatalf("錯誤訊息沒講是哪件事:%s", msg)
}
// 🔴 不再寫死「會自動恢復」這幾個字——改用產生端與消費端**共用**的那個判準。
// 寫死字串正是第三輪的病:我改了措辭,兩邊就對不上了(見 explainsWhySkipped)。
if !explainsWhySkipped(msg) {
t.Fatalf("🔴 這句話不會被 status.json 收進失敗清單 ⇒ 畫面上會沒有原因:%s", msg)
}
for _, banned := range []string{"context deadline", "HTTP", "timeout", "Client.Timeout"} {
if strings.Contains(msg, banned) {
t.Fatalf("🔴 錯誤訊息漏出內部語彙「%s」:%s", banned, msg)
}
}
}
// ②之二:等太久的事要寫進 status.json,畫面才有東西可以講。
func TestStallsLandInStatusJSON(t *testing.T) {
stallTestTimings(t, 200*time.Millisecond, 50*time.Millisecond)
origFetch := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "", false }
defer func() { fetchCloudVersion = origFetch }()
dead := blackHoleServer(t)
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "note.md"), []byte("# 一份筆記\n內容"), 0o644); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(t.TempDir(), "m.json")
cfg := &DirectConfig{
Manifest: manifestPath,
Accounts: []AccountConfig{{CypherURL: dead.URL, Namespace: "n", APIKey: "k",
WatchFolders: []string{root}}},
Library: "kb", MaxRemoved: DefaultMaxRemovedRatio,
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
ForceSync: true,
}
RunDirectOnce(cfg, false)
st, err := LoadSyncStatus(StatusFilePath(manifestPath))
if err != nil {
t.Fatalf("讀 status.json 失敗:%v", err)
}
if len(st.Stalls) == 0 {
t.Fatal("🔴 status.json 沒有 stalls——畫面上就只剩「開著、沒錯誤、什麼都不動」")
}
s := st.Stalls[0]
if s.Account == "" || s.Step == "" || s.Note == "" {
t.Fatalf("三件事要講齊(哪個帳號/哪件事/人話):%+v", s)
}
}
// ③ 一輪跑完,folder-trees.json 真的被重寫——即使雲端那邊完全不回應。
//
// 這一格就是使用者實際撞到的症狀:畫面上的資料夾結構永遠停在上一版。
// 樹是**本機算出來的**,不該因為送不上雲端就連本機那份都不寫。
func TestFolderTreeStoreRewrittenEvenWhenCloudNeverAnswers(t *testing.T) {
stallTestTimings(t, 200*time.Millisecond, 50*time.Millisecond)
origFetch := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "", false }
defer func() { fetchCloudVersion = origFetch }()
dead := blackHoleServer(t)
root := t.TempDir()
for _, d := range []string{"a", "b", "c"} {
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, d, "n.md"), []byte("# n\n內容"), 0o644); err != nil {
t.Fatal(err)
}
}
manifestPath := filepath.Join(t.TempDir(), "m.json")
cfg := &DirectConfig{
Manifest: manifestPath,
Accounts: []AccountConfig{{CypherURL: dead.URL, Namespace: "n", APIKey: "k",
WatchFolders: []string{root}}},
Library: "kb", MaxRemoved: DefaultMaxRemovedRatio,
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
ForceSync: true,
}
RunDirectOnce(cfg, false)
treePath := FolderTreeStorePath(manifestPath)
store, err := LoadFolderTreeStore(treePath)
if err != nil {
t.Fatalf("🔴 一輪跑完卻沒有 folder-trees.json%v", err)
}
tree, ok := store.Trees[root]
if !ok {
t.Fatalf("快照裡沒有這個根:%+v", store.Trees)
}
if len(tree.Nodes) < 4 { // 根 + a/b/c
t.Fatalf("🔴 樹只有 %d 個節點(該有根+三個子資料夾):%+v", len(tree.Nodes), tree.Nodes)
}
// 再跑一輪,mtime 要往前走——「畫面永遠停在上一版」的反面就是這一格。
before, err := os.Stat(treePath)
if err != nil {
t.Fatal(err)
}
time.Sleep(20 * time.Millisecond)
RunDirectOnce(cfg, false)
after, err := os.Stat(treePath)
if err != nil {
t.Fatal(err)
}
if !after.ModTime().After(before.ModTime()) {
t.Fatalf("🔴 第二輪沒有重寫 folder-trees.json%v → %v", before.ModTime(), after.ModTime())
}
}
// 連線被拒(很快就回來的錯)不該被當成「等太久」——
// 把它記進斷路器的話,正常的斷網會讓帳號被誤判成沒有回應。
func TestFastFailureIsNotCountedAsStall(t *testing.T) {
stallTestTimings(t, 2*time.Second, 50*time.Millisecond)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
url := srv.URL
srv.Close() // 關掉 ⇒ 連線立刻被拒
g := newRoundGuard()
g.announce = func(StalledCall) {}
cfg := &DirectConfig{CypherURL: url, Namespace: "n", APIKey: "k", guard: g}
if _, _, err := cfg.postJSON(stepIngestCard, url+"/x", map[string]any{}); err == nil {
t.Fatal("連不上卻回 nil error")
}
if len(g.Stalls()) != 0 {
t.Fatalf("🔴 立刻失敗被記成「等太久」:%+v", g.Stalls())
}
if g.skipReason(instanceHostOf(url)) != "" {
t.Fatal("🔴 一次連線被拒就把整個帳號停掉了——那是把斷網懲罰成停機")
}
}
// 沒裝 guard(低層函式被單獨呼叫)時仍然要有上限——
// 「沒裝閘」不可以等於「沒有上限」,那是這條線最初出事的樣子。
func TestGateAlwaysHasADeadlineEvenWithoutGuard(t *testing.T) {
stallTestTimings(t, 200*time.Millisecond, 50*time.Millisecond)
dead := blackHoleServer(t)
cfg := &DirectConfig{CypherURL: dead.URL, Namespace: "n", APIKey: "k"} // guard == nil
start := time.Now()
_, _, err := cfg.postJSON(stepIngestCard, dead.URL+"/x", map[string]any{})
if err == nil {
t.Fatal("端點不回應卻回了 nil error")
}
if el := time.Since(start); el > 2*time.Second {
t.Fatalf("🔴 沒有 guard 就沒有上限了(等了 %v", el)
}
}
// 🔴 慢但**成功**的雲端,不准被講成「沒有回應」,也不准害後面的檔被跳過。
//
// 這一條是 2026-08-28 第三輪的實撞:`送出一份筆記` 實測 33〜57 秒,而我第一輪
// 憑「零 LLM 應該很快」的**推論**給了 60 秒上限 ⇒ 尾巴被剪斷 2 發 ⇒ 斷路器跳 ⇒
// **後面 9 個檔全被跳過**,畫面還對使用者說「知識庫現在沒有回應」。
// 那台知識庫一路都在回應(同一輪成功送出 8 份筆記),只是慢。
func TestSlowButSucceedingCloudIsNotCalledUnresponsive(t *testing.T) {
// 上限給 2 秒,伺服器每發 300ms——**遠低於上限**,就是「慢但會成功」。
stallTestTimings(t, 2*time.Second, 50*time.Millisecond)
origFetch := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "", false }
defer func() { fetchCloudVersion = origFetch }()
var hits int64
slow := slowButHealthyServer(t, 300*time.Millisecond, &hits)
root := t.TempDir()
for i := range [8]struct{}{} {
name := filepath.Join(root, "note"+string(rune('a'+i))+".md")
if err := os.WriteFile(name, []byte("# 筆記\n內容"), 0o644); err != nil {
t.Fatal(err)
}
}
manifestPath := filepath.Join(t.TempDir(), "m.json")
cfg := &DirectConfig{
Manifest: manifestPath,
Accounts: []AccountConfig{{CypherURL: slow.URL, Namespace: "n", APIKey: "k",
WatchFolders: []string{root}}},
Library: "kb", MaxRemoved: DefaultMaxRemovedRatio,
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
ForceSync: true,
}
results, _, _ := RunDirectOnce(cfg, false)
// ① 斷路器不准跳:它一路都在回應。
if note := cfg.unreachableNote(); note != "" {
t.Fatalf("🔴 慢但成功的雲端被判成不能打了:%s", note)
}
for _, s := range cfg.guard.Stalls() {
if s.Skipped {
t.Fatalf("🔴 有一筆標成「這一輪跳過該帳號」:%+v", s)
}
}
// ② 一個檔都不准被「帳號沒回應」這個理由跳過。
skipped := 0
for _, r := range results {
if r.Status == "skipped" && (strings.Contains(r.Error, "沒有回應") ||
strings.Contains(r.Error, "回得太慢")) {
skipped++
}
}
if skipped > 0 {
t.Fatalf("🔴 有 %d 個檔因為「帳號沒回應」被跳過,但雲端每一發都成功了", skipped)
}
// ③ 就算真的有一發逾時,只要它這一輪回應過,措辭就不准是「沒有回應」。
g := newRoundGuard()
g.announce = func(StalledCall) {}
g.succeeded("h")
msg := g.strike("h", stepIngestCard, 3*time.Second)
if strings.Contains(msg, "沒有回應") {
t.Fatalf("🔴 它回應過,卻還是說「沒有回應」:%s", msg)
}
if !strings.Contains(msg, "回得太慢") {
t.Fatalf("措辭應該是「回得太慢」:%s", msg)
}
}
// 「連續」兩個字必須是真的:成功一次就把計數歸零。
func TestStrikesAreActuallyConsecutive(t *testing.T) {
g := newRoundGuard()
g.announce = func(StalledCall) {}
g.strike("h", stepIngestCard, time.Second) // 第 1 次逾時
g.succeeded("h") // 中間成功了一次
g.strike("h", stepIngestCard, time.Second) // 又逾時一次 ⇒ 這是「第 1 次連續」
if r := g.skipReason("h"); r != "" {
t.Fatalf("🔴 中間成功過,卻仍然跳閘了——訊息裡的「連續」是假的:%s", r)
}
g.strike("h", stepIngestCard, time.Second) // 真的連續第 2 次 ⇒ 才該跳
if g.skipReason("h") == "" {
t.Fatal("真的連續兩次逾時了,該跳閘卻沒跳")
}
}
+23 -1
View File
@@ -14,6 +14,7 @@ import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
@@ -46,12 +47,23 @@ type Status struct {
Rounds int // 累計掃描輪數
Restarts int // 累計重起次數
LastError string // 最近一次錯誤(stderr 末行 / 退出原因)
// Waiting=正在等某一發雲端回覆的白話說明(`inkstone/arcrun-rag#153`)。
// 空=沒有人在等。它不是錯誤——所以不寫進 LastError(那一格會被診斷檔
// 當成「出事了」帶出去,見 diagnostics_export.go 的 engineLastErrorFor)。
Waiting string
}
// round 對應 collector direct 每輪印到 stdout 的 JSON(見 direct.go runOne)。
type round struct {
At string `json:"at"`
Phase string `json:"phase"` // t191"start"(開工)/"done"(跑完);舊版沒有此欄=空
// `inkstone/arcrun-rag#153`"waiting"=這一發等太久了,還沒跑完。
// 🔴 它**不是**一輪的結束:下面的 decode 迴圈把任何非 start 的值都當成
// 「跑完了」,不特別認得它的話,托盤會在同步途中跳回「看守中」——
// 正是 t191 修掉的那個病(「看起來好像就做完了」)。
Account string `json:"account,omitempty"` // 哪個知識庫帳號
Step string `json:"step,omitempty"` // 哪件事(白話)
WaitedSec int `json:"waited_sec,omitempty"` // 等了幾秒
Folder string `json:"folder"`
Results []json.RawMessage `json:"results"`
@@ -327,11 +339,21 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
at := parseAt(r.At)
if r.Phase == "start" {
// t191:開工 → 顯示「同步中…」。不累加 Rounds(那是「完成幾輪」)。
s.setState(func(st *Status) { st.State = StateSyncing })
s.setState(func(st *Status) { st.State = StateSyncing; st.Waiting = "" })
continue
}
if r.Phase == "waiting" {
// #153:還在等某一發回覆 ⇒ 仍在同步中,**不算跑完一輪**。
// 把「哪個帳號、哪件事、等了多久」留在狀態上,托盤才講得出來
// ——不然使用者看到的就是「開著、沒有錯誤、什麼都不動」。
note := fmt.Sprintf("正在等「%s」回覆「%s」,已經等了 %d 秒",
r.Account, r.Step, r.WaitedSec)
s.setState(func(st *Status) { st.State = StateSyncing; st.Waiting = note })
continue
}
s.setState(func(st *Status) {
st.State = StateWatching
st.Waiting = "" // 跑完了=沒有人在等,別讓上一輪的等待訊息留在畫面上
st.Rounds++
if !at.IsZero() {
st.LastRoundAt = at
+54
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
)
// AccountSyncStatus 彙總單一帳號的每輪同步結果(t104 多帳號看守)。
@@ -104,6 +105,23 @@ type SyncStatus struct {
// 與 Retiring 同族的現況快照,見 ResyncStatus 註解。
Resync map[string]ResyncStatus `json:"resync,omitempty"`
// FolderProgress**逐個看守資料夾**的同步現況(key=資料夾路徑,`inkstone/arcrun-rag#159`)。
//
// 🔴 為什麼要有:小幫手畫面上每個資料夾都要能一眼看出「同步了沒」,而在這之前
// 那一格唯一的資料來源是 #140 的補送說明——**它只在有補送的時候才有值**,
// 於是畫面只好拿「有沒有那句話」當狀態。2026-08-28 leo 看到七列有六列寫「補送中」,
// 連 pending 已經是 0 的也照樣寫(實測 youlinhsieh-test1pending=0repaired=2
// 畫面仍標補送中)——那個標籤量的根本不是同步狀態。
//
// 🔴 **不另算一套**:值就是 (*Manifest).Progress() 的原件,與首頁那行大數字、
// 診斷檔用同一個函式。逐資料夾加起來會等於首頁總量,因為它們本來就是同一組
// 數字被切開,不是兩份實作。
//
// 與 FolderPlans 同族的現況快照;某一輪那個根沒掃成(payload 為 nil)時**沿用
// 上一輪**,不要讓使用者看到昨天還好好的資料夾突然沒有狀態
// (同 MergeFolderTreeStore ② 的理由)。
FolderProgress map[string]SyncProgress `json:"folder_progress,omitempty"`
// 🔴 G-6.2「不准安靜地略過」(2026-08-06):副檔名不在 allowedExt 的檔案,
// 以前在 scan.go 的白名單閘就 `return nil` 蒸發了——沒事件、沒紀錄、沒畫面。
// 使用者丟一份 .doc 進資料夾,得到的回應是**完全的沉默**。
@@ -140,6 +158,17 @@ type SyncStatus struct {
// 不會被清成 0(現況快照,不是本輪計數)。
Progress SyncProgress `json:"progress"`
FailureBreakdown FailureBreakdown `json:"failure_breakdown"`
// Stalls=這一輪「等太久」的事(`inkstone/arcrun-rag#153`)。
//
// 為什麼要有這一格:2026-08-28 實撞的畫面是**小幫手開著、沒有錯誤訊息、
// 什麼都不動**——同步停在一發等不到回覆的請求上,而使用者看得到的每一個
// 數字都還是上一輪的。**靜默的等待跟當掉對使用者是同一件事**,
// 所以「哪個帳號、哪件事、等了多久」要有地方講。
//
// 與 SkippedDocs 同族:每輪重算的現況快照,不進 CarryForwardActivity
//(上一輪等太久不代表這一輪也在等,帶下來就會變成一個永遠擦不掉的警告)。
Stalls []StalledCall `json:"stalls,omitempty"`
}
// FolderPlanStatus=某個看守資料夾這一輪用了什麼收檔策略、據此少收了什麼
@@ -227,3 +256,28 @@ func LoadSyncStatus(path string) (SyncStatus, error) {
func SyncNowSignalPath(manifestPath string) string {
return filepath.Join(filepath.Dir(manifestPath), "sync-now")
}
// explainsWhySkipped 回答:「這一則 `skipped` 的訊息,講得出**為什麼**嗎?」
// 講得出 ⇒ 收進 status.json 的失敗清單,畫面才有原因可講;講不出 ⇒ 不佔畫面。
//
// 🔴 為什麼要抽成具名函式(`inkstone/arcrun-rag#153` 第三輪,2026-08-28 差點實撞):
// 這個判準本來是**寫死在 direct.go 裡的三個字串比對**,而產生那些訊息的地方在別的檔。
// 我改了斷路器的措辭(「會自動恢復」→「稍後會自動再試」),兩邊當場對不上——
// 後果不是報錯,是**那 9 個被跳過的檔會連一句原因都沒有地從畫面上消失**,
// 正是這個 repo 一再修的「安靜地略過」。
//
// 抽成一個函式解不掉「字串比對很脆」這件事,但它解掉了**兩邊會各自漂走**:
// 現在只有一個地方定義「講得出原因」,而且有測試守著(sync_status_test.go)。
// 新增訊息時,讓它通過這個函式,或把新的識別字加在這裡——不要在別處另開一張表。
func explainsWhySkipped(msg string) bool {
for _, mark := range []string{
"後重試", // 退避中:「上次失敗(第 N 次),X 後重試」
"已暫停自動重試", // 連續失敗到上限
"會自動恢復", // 額度冷卻/帳號暫時打不通,之後自己會好
} {
if strings.Contains(msg, mark) {
return true
}
}
return false
}
+45
View File
@@ -5,7 +5,9 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// ── FindClaudeBin fallbackt92)──────────────────────────────────────────────
@@ -268,3 +270,46 @@ func TestCarryForwardActivity(t *testing.T) {
}
})
}
// 🔴 產生訊息的那一端,和決定「這則訊息要不要上畫面」的那一端,必須永遠對得上。
//
// 2026-08-28 第三輪差點實撞:我改了斷路器的措辭,判準沒跟著改 ⇒ 那 9 個被跳過的檔
// 會**連一句原因都沒有地從畫面消失**。不會報錯、不會有紅字,就是安靜地不見。
// 這條網把兩端綁在一起:斷路器講的每一種話,都要能被 explainsWhySkipped 認得。
func TestSkipMessagesAlwaysExplainThemselves(t *testing.T) {
g := newRoundGuard()
g.announce = func(StalledCall) {}
// ① 從沒回應過的帳號(措辭:沒有回應)
first := g.strike("dead.example", stepIngestCard, 60*time.Second)
// ② 連續第二次 ⇒ 跳閘,這句會被貼在每一個被跳過的檔上
tripped := g.strike("dead.example", stepIngestCard, 60*time.Second)
// ③ 回應過、只是慢的帳號(措辭:回得太慢)
g.succeeded("slow.example")
slow := g.strike("slow.example", stepIngestCard, 60*time.Second)
for name, msg := range map[string]string{
"第一次逾時": first,
"跳閘(貼在被跳過的檔上)": tripped,
"回得太慢": slow,
} {
if msg == "" {
t.Fatalf("%s:訊息是空的", name)
}
if !explainsWhySkipped(msg) {
t.Fatalf("🔴 %s 的訊息不會被 status.json 收進失敗清單 ⇒ "+
"畫面上那些檔會沒有任何原因地消失。\n訊息:%s", name, msg)
}
// 產品文案:不准漏出內部語彙
for _, banned := range []string{"HTTP", "context", "timeout", "goroutine", "token"} {
if strings.Contains(msg, banned) {
t.Fatalf("🔴 %s 漏出內部語彙「%s」:%s", name, banned, msg)
}
}
}
// 反面:一句不講原因的話,不該被收進去(不然畫面會被噪音塞滿)
if explainsWhySkipped("跳過") {
t.Fatal("不講原因的訊息不該被當成「講得出原因」")
}
}