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:
@@ -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(空=沿用 namespace,demo 慣例)
|
||||
Email string `json:"email,omitempty"` // 實例主身分(t26;人人記得自己的 email,CF 全程隱形)
|
||||
InstanceName string `json:"instance_name,omitempty"` // 暱稱(t26 選配;不取就顯示 email)
|
||||
Library string `json:"library"` // 藏書地圖歸庫鍵(空=kb;per-folder 未指定時的後備)
|
||||
// t52(leo 2026-07-25 裁決:「資料夾=庫」——企業有 10 個資料夾,財務/人事不准任何人看,
|
||||
// 全塞一個庫=找死):每個看守資料夾對應自己的庫。key=資料夾絕對路徑,value=庫名。
|
||||
// 未列出的資料夾=用資料夾名 slug 當庫名(librarySlug);再不行才退回 Library。
|
||||
// t104:多帳號清單(新制)。有值時頂層連線欄位僅保留讀取相容——
|
||||
// 啟動時若無 Accounts 但有舊的頂層 CypherURL,LoadDirectConfig 自動包成 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_direct;extractor 模式不用)
|
||||
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.ClaudeBin(in-memory,呼叫端存檔)。
|
||||
// - 每輪結束寫 ~/.arcrun-rag/status.json(t91 狀態可見性)。
|
||||
// - 每輪結束寫 ~/.arcrun-rag/status.json(t91 狀態可見性,含 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)
|
||||
|
||||
// t103:per-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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user