feat(t104): 多帳號同時看守——切換概念消滅

leo 架構依據:「它只是一個門,幾個帳號通過它同步並沒有影響」+
「我不是 Google Drive……不提供暫存空間,daemon 工作輕巧,多帳號只是頁簽問題」
(D-daemon-not-Drive)。
Accounts[] 每帳號獨立連線+資料夾;舊 config 冪等遷移 accounts[0];
逐帳號同步一敗不擋全;status.json 分帳;托盤每帳號一分組;
「連上知識庫」→「+新增帳號…」(append 非替換);t86 切換清空退役;
t101 刪除作用於正確帳號。+873/-149、collector 5+tray 5 新測試,
兩模組 go test 全綠(總管親跑)。
(實作=子 CC;驗證+commit=總管)
This commit is contained in:
2026-07-28 16:45:14 +08:00
parent f8450815d3
commit bf1f0d9991
5 changed files with 871 additions and 147 deletions
+173 -52
View File
@@ -34,23 +34,36 @@ import (
"time"
)
// AccountConfig 單一帳號的連線設定(t104 多帳號同時看守)。
// 每個帳號代表一個 Arcrun 知識庫實例;Extractor/Manifest 等機器層級設定住在 DirectConfig 頂層。
type AccountConfig struct {
InstanceName string `json:"instance_name,omitempty"`
Email string `json:"email,omitempty"`
CypherURL string `json:"cypher_url"`
Namespace string `json:"namespace"`
APIKey string `json:"api_key,omitempty"`
WatchFolders []string `json:"watch_folders,omitempty"` // 此帳號看守的資料夾(多根)
Libraries map[string]string `json:"libraries,omitempty"` // 資料夾→庫對映(t52
}
// DirectConfig 是 direct 模式的設定檔(JSON)。設定只走檔案/環境,不落 code。
type DirectConfig struct {
WatchFolder string `json:"watch_folder,omitempty"` // 監看的知識資料夾(單數舊制;與 watch_folders 至少填一)
WatchFolders []string `json:"watch_folders,omitempty"` // 監看的知識資料夾清單(daemon-beta task 1 多資料夾)
Manifest string `json:"manifest"` // manifest JSON 路徑(必填;多資料夾時為基底名,每根一份帶尾碼)
CypherURL string `json:"cypher_url"` // 實例 cypher base(必填),如 https://arcrun-cypher-executor.<acct>.workers.dev
Namespace string `json:"namespace"` // 租戶 namespace(必填),如 demo
APIKey string `json:"api_key"` // X-Arcrun-API-Key(空=沿用 namespacedemo 慣例
Email string `json:"email,omitempty"` // 實例主身分(t26;人人記得自己的 email,CF 全程隱形
InstanceName string `json:"instance_name,omitempty"` // 暱稱(t26 選配;不取就顯示 email
Library string `json:"library"` // 藏書地圖歸庫鍵(空=kb;per-folder 未指定時的後備
// t52leo 2026-07-25 裁決:「資料夾=庫」——企業有 10 個資料夾,財務/人事不准任何人看,
// 全塞一個庫=找死):每個看守資料夾對應自己的庫。key=資料夾絕對路徑,value=庫名。
// 未列出的資料夾=用資料夾名 slug 當庫名(librarySlug);再不行才退回 Library。
// t104:多帳號清單(新制)。有值時頂層連線欄位僅保留讀取相容——
// 啟動時若無 Accounts 但有舊的頂層 CypherURLLoadDirectConfig 自動包成 Accounts[0]。
Accounts []AccountConfig `json:"accounts,omitempty"`
WatchFolder string `json:"watch_folder,omitempty"` // 監看的知識資料夾(單數舊制;與 watch_folders 至少填一)
WatchFolders []string `json:"watch_folders,omitempty"` // 監看的知識資料夾清單(daemon-beta task 1 多資料夾)
Manifest string `json:"manifest"` // manifest JSON 路徑(必填;多資料夾時為基底名,每根一份帶尾碼
CypherURL string `json:"cypher_url,omitempty"` // 實例 cypher base(舊制;新制走 Accounts
Namespace string `json:"namespace,omitempty"` // 租戶 namespace(舊制;新制走 Accounts
APIKey string `json:"api_key,omitempty"` // X-Arcrun-API-Key(舊制
Email string `json:"email,omitempty"` // 實例主身分(舊制)
InstanceName string `json:"instance_name,omitempty"` // 暱稱(舊制)
Library string `json:"library"` // 藏書地圖歸庫鍵(空=kb;per-folder 未指定時的後備)
// t52:每個看守資料夾對應自己的庫(key=絕對路徑,value=庫名);新制走 AccountConfig.Libraries。
Libraries map[string]string `json:"libraries,omitempty"`
IngestWF string `json:"ingest_workflow"` // 直送萃取 workflow 名(空=rag_ingest_directextractor 模式不用
RemovedWF string `json:"removed_workflow"` // 下架 workflow 名(空=rag_takedown_direct;吃 {page_name,path}
IngestWF string `json:"ingest_workflow"` // 直送萃取 workflow 名(空=rag_ingest_direct
RemovedWF string `json:"removed_workflow"` // 下架 workflow 名(空=rag_takedown_direct
// —— 四步定稿(daemon-beta t3/t4/t6):本地萃卡模式 ——
Extractor string `json:"extractor,omitempty"` // "claude""gemma";空=舊制(內容直送雲端萃)
ClaudeBin string `json:"claude_bin,omitempty"` // claude 執行檔(空=PATH 找 claude
@@ -126,6 +139,8 @@ func expandHome(p string) string {
}
// LoadDirectConfig 讀設定檔並補預設值 + 基本驗證。
// t104 向後相容遷移:若無 Accounts 但有舊的頂層 CypherURL,自動包成 Accounts[0](記憶體遷移;
// 磁碟回寫由呼叫端在合適時機(如 saveDirectConfig)完成)。
func LoadDirectConfig(path string) (*DirectConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
@@ -135,22 +150,59 @@ func LoadDirectConfig(path string) (*DirectConfig, error) {
if err := json.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("config JSON 解析失敗:%w", err)
}
var missing []string
if c.WatchFolder == "" && len(c.WatchFolders) == 0 {
missing = append(missing, "watch_folder(或 watch_folders")
// t39:路徑欄位一律展開 `~/`(先展開才能正確計算 Folders())
c.Manifest = expandHome(c.Manifest)
c.WatchFolder = expandHome(c.WatchFolder)
for i, p := range c.WatchFolders {
c.WatchFolders[i] = expandHome(p)
}
for i := range c.Accounts {
for j, p := range c.Accounts[i].WatchFolders {
c.Accounts[i].WatchFolders[j] = expandHome(p)
}
}
// t104:舊格式遷移——頂層 CypherURL → Accounts[0](冪等:有 Accounts 就跳過)
if len(c.Accounts) == 0 && c.CypherURL != "" {
c.Accounts = []AccountConfig{{
InstanceName: c.InstanceName,
Email: c.Email,
CypherURL: c.CypherURL,
Namespace: c.Namespace,
APIKey: c.APIKey,
Libraries: c.Libraries,
WatchFolders: c.Folders(), // 正規化後的監看清單
}}
}
// 驗證
var missing []string
if c.Manifest == "" {
missing = append(missing, "manifest")
}
if c.CypherURL == "" {
missing = append(missing, "cypher_url")
if len(c.Accounts) == 0 {
missing = append(missing, "accounts(或 cypher_url 連線設定)")
}
if c.Namespace == "" {
missing = append(missing, "namespace")
for i, acc := range c.Accounts {
if acc.CypherURL == "" {
missing = append(missing, fmt.Sprintf("accounts[%d] 缺 cypher_url", i))
}
if acc.Namespace == "" {
missing = append(missing, fmt.Sprintf("accounts[%d] 缺 namespace", i))
}
}
if len(missing) > 0 {
return nil, fmt.Errorf("config 缺必填欄位:%s", strings.Join(missing, ", "))
}
// 補各帳號預設值
for i := range c.Accounts {
if c.Accounts[i].APIKey == "" {
c.Accounts[i].APIKey = c.Accounts[i].Namespace
}
c.Accounts[i].CypherURL = strings.TrimSuffix(c.Accounts[i].CypherURL, "/")
}
// 頂層後備值(舊制相容或被 makeAccountSubConfig 繼承)
if c.APIKey == "" {
c.APIKey = c.Namespace
}
@@ -178,12 +230,8 @@ func LoadDirectConfig(path string) (*DirectConfig, error) {
if c.MaxRemoved <= 0 {
c.MaxRemoved = DefaultMaxRemovedRatio
}
c.CypherURL = strings.TrimSuffix(c.CypherURL, "/")
// t39:路徑欄位一律展開 `~/`(config 是給人填/人讀的,波浪號是人的寫法)
c.Manifest = expandHome(c.Manifest)
c.WatchFolder = expandHome(c.WatchFolder)
for i, p := range c.WatchFolders {
c.WatchFolders[i] = expandHome(p)
if c.CypherURL != "" {
c.CypherURL = strings.TrimSuffix(c.CypherURL, "/")
}
return &c, nil
}
@@ -300,7 +348,8 @@ func pageNameOf(relPath string) string {
// DirectResult 是單一事件的直送結果(隨每輪 log 輸出)。
type DirectResult struct {
Root string `json:"root,omitempty"` // 多資料夾時標明事件屬於哪個根
Account string `json:"account,omitempty"` // t104: cypher_url host(多帳號標的)
Root string `json:"root,omitempty"` // 多資料夾時標明事件屬於哪個根
Type string `json:"type"`
Path string `json:"path"`
Status string `json:"status"` // ingested | removed | planned | failed | skipped
@@ -308,17 +357,40 @@ type DirectResult struct {
Error string `json:"error,omitempty"`
}
// RunDirectOnce 對每個監看根掃一輪並彙總結果(daemon-beta task 1 多資料夾)。
// 單根行為與舊制完全相同(含 manifest 路徑)。回傳彙總結果與退出碼建議(任一根失敗=1)
// makeAccountSubConfig 從帳號設定建出單帳號用的 DirectConfig,繼承機器層級欄位(t104)。
// 用於 RunDirectOnce 逐帳號掃描,每帳號得到獨立的 CypherURL/Namespace/WatchFolders 等
func (c *DirectConfig) makeAccountSubConfig(acc AccountConfig) *DirectConfig {
sub := *c // 複製機器層級欄位
sub.Accounts = nil
sub.CypherURL = strings.TrimSuffix(acc.CypherURL, "/")
sub.Namespace = acc.Namespace
sub.APIKey = acc.APIKey
if sub.APIKey == "" {
sub.APIKey = sub.Namespace
}
sub.Email = acc.Email
sub.InstanceName = acc.InstanceName
sub.WatchFolder = ""
sub.WatchFolders = acc.WatchFolders
sub.Libraries = acc.Libraries
if sub.Library == "" {
sub.Library = "kb"
}
return &sub
}
// RunDirectOnce 對每個帳號的每個監看根掃一輪並彙總結果(t104 多帳號同時看守)。
// 單帳號行為與舊制完全相同(含 manifest 路徑)。回傳彙總結果與退出碼建議(任一根失敗=1)。
// 額外:
// - 預檢 extractor 可用性(t92-②),有 fallback 時更新 cfg.ClaudeBinin-memory,呼叫端存檔)。
// - 每輪結束寫 ~/.arcrun-rag/status.jsont91 狀態可見性)。
// - 每輪結束寫 ~/.arcrun-rag/status.jsont91 狀態可見性,含 per-account 雲端版本)。
// - 一個帳號失敗不擋其他帳號繼續同步(t104 隔離)。
func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *TriggerPayload) {
results := []DirectResult{}
exit := 0
var lastPayload *TriggerPayload
// t92-②:預檢 extractor,有 fallback 路徑時就地更新 cfg.ClaudeBin(供下游直接使用)
// t92-②:預檢 extractor(機器層級),有 fallback 路徑時就地更新 cfg.ClaudeBin。
extractorOK := true
extractorError := ""
if cfg.Extractor == "claude" {
@@ -336,34 +408,75 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
}
}
multi := len(cfg.Folders()) > 1
for _, root := range cfg.Folders() {
r, e, p := runDirectOnceRoot(cfg, root, dryRun)
if multi {
for i := range r {
r[i].Root = root
}
}
results = append(results, r...)
if e != 0 {
exit = e
}
if p != nil {
lastPayload = p
}
// t104:解出有效帳號清單(向後相容:無 Accounts 但有頂層 CypherURL 時視為單帳號)
accounts := cfg.Accounts
if len(accounts) == 0 && cfg.CypherURL != "" {
accounts = []AccountConfig{{
InstanceName: cfg.InstanceName,
Email: cfg.Email,
CypherURL: cfg.CypherURL,
Namespace: cfg.Namespace,
APIKey: cfg.APIKey,
Libraries: cfg.Libraries,
WatchFolders: cfg.Folders(),
}}
}
// t91:每輪寫狀態檔(只有 extractor 模式才有意義的計數;direct 雲端萃模式 extracted_ok=0)。
accountDetails := map[string]AccountSyncStatus{}
for _, acc := range accounts {
if acc.CypherURL == "" || acc.Namespace == "" {
continue // 跳過設定不完整的帳號
}
accCfg := cfg.makeAccountSubConfig(acc)
accHost := instanceHostOf(acc.CypherURL)
// t103per-account 雲端版本偵測
cloudVer, cloudOK := fetchCloudVersion(accCfg.CypherURL)
accSt := AccountSyncStatus{
LastSync: time.Now().Format(time.RFC3339),
CloudVersion: cloudVer,
CloudCheckOK: cloudOK,
}
multi := len(accCfg.Folders()) > 1
for _, root := range accCfg.Folders() {
r, e, p := runDirectOnceRoot(accCfg, root, dryRun)
if multi {
for i := range r {
r[i].Root = root
}
}
for i := range r {
r[i].Account = accHost // t104: 標明所屬帳號
if cfg.Extractor != "" {
switch r[i].Status {
case "ingested":
accSt.ExtractedOK++
case "failed":
accSt.ExtractFailed++
}
}
}
results = append(results, r...)
if e != 0 {
exit = e // 任一帳號任一根失敗=整體 exit 1,但不停其他帳號
}
if p != nil {
lastPayload = p
}
}
accountDetails[accHost] = accSt
}
// t91:每輪寫狀態檔(含 per-account 雲端版本與萃取計數)。
if !dryRun && cfg.Manifest != "" {
// t103:每輪順手 GET /health 取雲端版本(5s timeout,失敗靜默)。
cloudVer, cloudOK := fetchCloudVersion(cfg.CypherURL)
st := SyncStatus{
LastSync: time.Now().Format(time.RFC3339),
ExtractorOK: extractorOK,
ExtractorError: extractorError,
CloudVersion: cloudVer,
CloudCheckOK: cloudOK,
AccountDetails: accountDetails,
}
// 頂層彙總(向後相容:單帳號時填頂層欄位讓舊版 tray 仍能讀)
if cfg.Extractor != "" {
for _, r := range results {
switch r.Status {
@@ -378,6 +491,14 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
}
}
}
// 單帳號時把 cloud version 也填頂層(向後相容)
if len(accountDetails) == 1 {
for _, v := range accountDetails {
st.CloudVersion = v.CloudVersion
st.CloudCheckOK = v.CloudCheckOK
break
}
}
if serr := SaveSyncStatus(StatusFilePath(cfg.Manifest), st); serr != nil {
fmt.Fprintf(os.Stderr, "status 寫入失敗(不擋看守):%v\n", serr)
}