From b355165180952dcf88930bc2573ac802fb72d701 Mon Sep 17 00:00:00 2001 From: richblack Date: Fri, 24 Jul 2026 12:09:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(daemon-beta=20t1):=20=E5=A4=9A=E8=B3=87?= =?UTF-8?q?=E6=96=99=E5=A4=BE=20config=E2=80=94=E2=80=94watch=5Ffolders[]+?= =?UTF-8?q?=E5=96=AE=E6=95=B8=E7=9B=B8=E5=AE=B9=E3=80=81manifest=20?= =?UTF-8?q?=E6=AF=8F=E6=A0=B9=E4=B8=80=E4=BB=BD=E3=80=81=E5=A4=9A=E6=A0=B9?= =?UTF-8?q?=E6=8E=83=E6=8F=8F=E6=A8=99=20Root=E3=80=81=E6=89=98=E7=9B=A4?= =?UTF-8?q?=E8=B3=87=E6=96=99=E5=B1=A4=E7=9B=B8=E5=AE=B9=EF=BC=9Brag-wave1?= =?UTF-8?q?=20closed=E2=86=92daemon-beta=20=E5=AF=A6=E4=BD=9C=E5=8D=B7=20a?= =?UTF-8?q?ctive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - direct.go: Folders() 正規化(去重保序)/manifestPathFor(單根沿用不丟狀態、多根 sha 尾碼)/RunDirectOnce 多根彙總 - 新測試 4 支全綠(單數相容/多根去重/缺欄驗證/多根 dry-run 標 Root);既有 7 支不動全綠 - tray: config 加 watch_folders 欄(防存檔洗掉)+addWatchFolder 資料層;勾選 UI=task 7 --- cmd/arcrun-tray/main.go | 48 +++++++++++----- direct.go | 96 +++++++++++++++++++++++++------ direct_multi_test.go | 124 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 236 insertions(+), 32 deletions(-) create mode 100644 direct_multi_test.go diff --git a/cmd/arcrun-tray/main.go b/cmd/arcrun-tray/main.go index ddf3fc9..6d02bfd 100644 --- a/cmd/arcrun-tray/main.go +++ b/cmd/arcrun-tray/main.go @@ -16,9 +16,9 @@ package main import ( - neturl "net/url" "encoding/json" "fmt" + neturl "net/url" "os" "path/filepath" @@ -36,16 +36,17 @@ import ( // directConfig 是 collector direct 的設定(與 collector/direct.go 的 DirectConfig 同結構; // 這裡只需讀寫 watch_folder,其餘由安裝器帶入)。 type directConfig struct { - WatchFolder string `json:"watch_folder"` - Manifest string `json:"manifest"` - CypherURL string `json:"cypher_url"` - Namespace string `json:"namespace"` - APIKey string `json:"api_key,omitempty"` - Library string `json:"library,omitempty"` - IngestWF string `json:"ingest_workflow,omitempty"` - RemovedWF string `json:"removed_workflow,omitempty"` - PollSec int `json:"poll_interval_sec,omitempty"` - MaxRemoved float64 `json:"max_removed_ratio,omitempty"` + WatchFolder string `json:"watch_folder,omitempty"` // 單數舊制(第一個資料夾的鏡像,維持相容) + WatchFolders []string `json:"watch_folders,omitempty"` // 多資料夾(daemon-beta task 1;完整勾選 UI=task 7) + Manifest string `json:"manifest"` + CypherURL string `json:"cypher_url"` + Namespace string `json:"namespace"` + APIKey string `json:"api_key,omitempty"` + Library string `json:"library,omitempty"` + IngestWF string `json:"ingest_workflow,omitempty"` + RemovedWF string `json:"removed_workflow,omitempty"` + PollSec int `json:"poll_interval_sec,omitempty"` + MaxRemoved float64 `json:"max_removed_ratio,omitempty"` } // appDir 是設定與 manifest 落地處:~/.arcrun-rag/ @@ -69,15 +70,36 @@ func loadConfig() *directConfig { if data, err := os.ReadFile(configPath()); err == nil { _ = json.Unmarshal(data, c) } - if c.WatchFolder == "" { + if c.WatchFolder == "" && len(c.WatchFolders) == 0 { c.WatchFolder = defaultWatchFolder() } + if c.WatchFolder == "" && len(c.WatchFolders) > 0 { + c.WatchFolder = c.WatchFolders[0] // 單數欄位=第一根鏡像(舊 collector 相容) + } if c.Manifest == "" { c.Manifest = filepath.Join(appDir(), "manifest.json") } return c } +// addWatchFolder 把資料夾加進監看清單(去重、保序),並維持單數欄位=第一根的鏡像。 +// 完整的「勾選/移除」UI 是 task 7;本函式先保證資料層正確。 +func addWatchFolder(c *directConfig, p string) { + if p == "" { + return + } + if len(c.WatchFolders) == 0 && c.WatchFolder != "" && c.WatchFolder != defaultWatchFolder() { + c.WatchFolders = []string{c.WatchFolder} + } + for _, f := range c.WatchFolders { + if f == p { + return + } + } + c.WatchFolders = append(c.WatchFolders, p) + c.WatchFolder = c.WatchFolders[0] +} + func saveConfig(c *directConfig) error { if err := os.MkdirAll(appDir(), 0o755); err != nil { return err @@ -123,7 +145,7 @@ func main() { if err != nil || uri == nil { return } - cfg.WatchFolder = uri.Path() + addWatchFolder(cfg, uri.Path()) if err := saveConfig(cfg); err != nil { dialog.ShowError(err, win) return diff --git a/direct.go b/direct.go index 551b324..bd0563f 100644 --- a/direct.go +++ b/direct.go @@ -21,6 +21,8 @@ package main import ( "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -33,16 +35,17 @@ import ( // DirectConfig 是 direct 模式的設定檔(JSON)。設定只走檔案/環境,不落 code。 type DirectConfig struct { - WatchFolder string `json:"watch_folder"` // 監看的知識資料夾(必填) - Manifest string `json:"manifest"` // manifest JSON 路徑(必填;不存在會建新) - CypherURL string `json:"cypher_url"` // 實例 cypher base(必填),如 https://arcrun-cypher-executor..workers.dev - Namespace string `json:"namespace"` // 租戶 namespace(必填),如 demo - APIKey string `json:"api_key"` // X-Arcrun-API-Key(空=沿用 namespace,demo 慣例) - Library string `json:"library"` // 藏書地圖歸庫鍵(空=kb) - IngestWF string `json:"ingest_workflow"` // 直送萃取 workflow 名(空=rag_ingest_direct) - RemovedWF string `json:"removed_workflow"` // 下架 workflow 名(空=rag_takedown_direct;吃 {page_name,path}) - PollSec int `json:"poll_interval_sec"` // 輪詢間隔秒(空/0=5) - MaxRemoved float64 `json:"max_removed_ratio"` // 大量刪除防呆門檻(空/0=0.4) + 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..workers.dev + Namespace string `json:"namespace"` // 租戶 namespace(必填),如 demo + APIKey string `json:"api_key"` // X-Arcrun-API-Key(空=沿用 namespace,demo 慣例) + Library string `json:"library"` // 藏書地圖歸庫鍵(空=kb) + IngestWF string `json:"ingest_workflow"` // 直送萃取 workflow 名(空=rag_ingest_direct) + RemovedWF string `json:"removed_workflow"` // 下架 workflow 名(空=rag_takedown_direct;吃 {page_name,path}) + PollSec int `json:"poll_interval_sec"` // 輪詢間隔秒(空/0=5) + MaxRemoved float64 `json:"max_removed_ratio"` // 大量刪除防呆門檻(空/0=0.4) } // LoadDirectConfig 讀設定檔並補預設值 + 基本驗證。 @@ -56,8 +59,8 @@ func LoadDirectConfig(path string) (*DirectConfig, error) { return nil, fmt.Errorf("config JSON 解析失敗:%w", err) } var missing []string - if c.WatchFolder == "" { - missing = append(missing, "watch_folder") + if c.WatchFolder == "" && len(c.WatchFolders) == 0 { + missing = append(missing, "watch_folder(或 watch_folders)") } if c.Manifest == "" { missing = append(missing, "manifest") @@ -93,6 +96,36 @@ func LoadDirectConfig(path string) (*DirectConfig, error) { return &c, nil } +// Folders 回傳監看根清單(正規化:單數舊制併入、去重、保序)。 +func (c *DirectConfig) Folders() []string { + seen := map[string]bool{} + var out []string + add := func(p string) { + if p == "" || seen[p] { + return + } + seen[p] = true + out = append(out, p) + } + add(c.WatchFolder) + for _, p := range c.WatchFolders { + add(p) + } + return out +} + +// manifestPathFor 回傳某根的 manifest 路徑。單根=沿用 cfg.Manifest(升級不丟既有狀態); +// 多根=每根一份,基底名加 root 絕對路徑的 sha256 前 8 碼尾碼(路徑穩定=尾碼穩定)。 +func (c *DirectConfig) manifestPathFor(absRoot string) string { + folders := c.Folders() + if len(folders) <= 1 { + return c.Manifest + } + sum := sha256.Sum256([]byte(absRoot)) + ext := filepath.Ext(c.Manifest) + return strings.TrimSuffix(c.Manifest, ext) + "-" + hex.EncodeToString(sum[:4]) + ext +} + // directHTTP 是 direct 模式共用的 HTTP client(萃取 workflow 可能同步跑 LLM,放寬 timeout)。 var directHTTP = &http.Client{Timeout: 300 * time.Second} @@ -136,6 +169,7 @@ func pageNameOf(relPath string) string { // DirectResult 是單一事件的直送結果(隨每輪 log 輸出)。 type DirectResult struct { + Root string `json:"root,omitempty"` // 多資料夾時標明事件屬於哪個根 Type string `json:"type"` Path string `json:"path"` Status string `json:"status"` // ingested | removed | planned | failed | skipped @@ -143,17 +177,41 @@ type DirectResult struct { Error string `json:"error,omitempty"` } -// RunDirectOnce 掃一輪、直送 added/modified/renamed、下架 removed,並在 2xx 後回寫 manifest。 -// 回傳本輪結果清單與退出碼建議(有失敗=1)。 +// RunDirectOnce 對每個監看根掃一輪並彙總結果(daemon-beta task 1 多資料夾)。 +// 單根行為與舊制完全相同(含 manifest 路徑)。回傳彙總結果與退出碼建議(任一根失敗=1)。 func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *TriggerPayload) { results := []DirectResult{} exit := 0 + var lastPayload *TriggerPayload + 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 + } + } + return results, exit, lastPayload +} - absRoot, err := filepath.Abs(cfg.WatchFolder) +// runDirectOnceRoot 對單一根掃一輪、直送 added/modified/renamed、下架 removed,2xx 後回寫該根 manifest。 +func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectResult, int, *TriggerPayload) { + results := []DirectResult{} + exit := 0 + + absRoot, err := filepath.Abs(root) if err != nil { return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil } - absManifest, err := filepath.Abs(cfg.Manifest) + absManifest, err := filepath.Abs(cfg.manifestPathFor(absRoot)) if err != nil { return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil } @@ -267,9 +325,9 @@ func runDirect(args []string) int { results, exit, _ := RunDirectOnce(cfg, *dryRun) out, _ := json.MarshalIndent(struct { At string `json:"at"` - Folder string `json:"folder"` + Folders []string `json:"folders"` Results []DirectResult `json:"results"` - }{time.Now().Format(time.RFC3339), cfg.WatchFolder, results}, "", " ") + }{time.Now().Format(time.RFC3339), cfg.Folders(), results}, "", " ") fmt.Println(string(out)) return exit } @@ -279,7 +337,7 @@ func runDirect(args []string) int { } // 常駐輪詢:純 stdlib ticker,跨平台。首輪立即跑。 fmt.Fprintf(os.Stderr, "collector direct daemon 啟動:監看 %s → %s(每 %ds 掃一輪)\n", - cfg.WatchFolder, cfg.triggerURL(cfg.IngestWF), cfg.PollSec) + strings.Join(cfg.Folders(), "、"), cfg.triggerURL(cfg.IngestWF), cfg.PollSec) runOne() ticker := time.NewTicker(time.Duration(cfg.PollSec) * time.Second) defer ticker.Stop() diff --git a/direct_multi_test.go b/direct_multi_test.go new file mode 100644 index 0000000..4b6eb2b --- /dev/null +++ b/direct_multi_test.go @@ -0,0 +1,124 @@ +// direct_multi_test.go — daemon-beta task 1 多資料夾 config(單數相容/manifest 分根/多根掃描)。 +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func writeDirectConfig(t *testing.T, dir string, cfg map[string]any) string { + t.Helper() + data, _ := json.Marshal(cfg) + p := filepath.Join(dir, "config.json") + if err := os.WriteFile(p, data, 0o600); err != nil { + t.Fatal(err) + } + return p +} + +// 單數舊制照舊可載入;Folders() 正規化=一根。 +func TestLoadDirectConfigSingularCompat(t *testing.T) { + dir := t.TempDir() + p := writeDirectConfig(t, dir, map[string]any{ + "watch_folder": "/tmp/kb", "manifest": filepath.Join(dir, "m.json"), + "cypher_url": "https://x.example", "namespace": "demo", + }) + c, err := LoadDirectConfig(p) + if err != nil { + t.Fatal(err) + } + if got := c.Folders(); len(got) != 1 || got[0] != "/tmp/kb" { + t.Fatalf("Folders()=%v", got) + } + // 單根 manifest 沿用原路徑(升級不丟狀態) + if mp := c.manifestPathFor("/tmp/kb"); mp != c.Manifest { + t.Fatalf("單根 manifest 應沿用原路徑,got %s", mp) + } +} + +// 只填 watch_folders 也合法;單數與清單並存時去重保序。 +func TestLoadDirectConfigMulti(t *testing.T) { + dir := t.TempDir() + p := writeDirectConfig(t, dir, map[string]any{ + "watch_folder": "/tmp/a", "watch_folders": []string{"/tmp/b", "/tmp/a", "/tmp/c"}, + "manifest": filepath.Join(dir, "m.json"), + "cypher_url": "https://x.example", "namespace": "demo", + }) + c, err := LoadDirectConfig(p) + if err != nil { + t.Fatal(err) + } + got := c.Folders() + want := []string{"/tmp/a", "/tmp/b", "/tmp/c"} + if len(got) != len(want) { + t.Fatalf("Folders()=%v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("Folders()[%d]=%s want %s", i, got[i], want[i]) + } + } + // 多根=每根 manifest 各一份、彼此不同、且尾碼對同一路徑穩定 + m1 := c.manifestPathFor("/tmp/a") + m2 := c.manifestPathFor("/tmp/b") + if m1 == m2 || m1 == c.Manifest { + t.Fatalf("多根 manifest 應分根:%s vs %s", m1, m2) + } + if m1 != c.manifestPathFor("/tmp/a") { + t.Fatal("同路徑尾碼應穩定") + } +} + +// 兩欄都空=缺必填。 +func TestLoadDirectConfigMissingFolders(t *testing.T) { + dir := t.TempDir() + p := writeDirectConfig(t, dir, map[string]any{ + "manifest": filepath.Join(dir, "m.json"), "cypher_url": "https://x.example", "namespace": "demo", + }) + if _, err := LoadDirectConfig(p); err == nil { + t.Fatal("兩欄皆空應報缺必填") + } +} + +// 多根 dry-run:各根的檔案都被掃到、事件標 Root、manifest 各自獨立。 +func TestRunDirectOnceMultiRootDryRun(t *testing.T) { + base := t.TempDir() + rootA := filepath.Join(base, "rootA") + rootB := filepath.Join(base, "rootB") + for _, d := range []string{rootA, rootB} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(rootA, "a.md"), []byte("# A"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootB, "b.md"), []byte("# B"), 0o644); err != nil { + t.Fatal(err) + } + cfg := &DirectConfig{ + WatchFolders: []string{rootA, rootB}, + Manifest: filepath.Join(base, "manifest.json"), + CypherURL: "https://x.example", Namespace: "demo", + MaxRemoved: DefaultMaxRemovedRatio, + } + results, exit, _ := RunDirectOnce(cfg, true) + if exit != 0 { + t.Fatalf("dry-run 不應失敗:%+v", results) + } + byRoot := map[string][]string{} + for _, r := range results { + if r.Status != "planned" { + t.Fatalf("dry-run 事件應為 planned:%+v", r) + } + byRoot[r.Root] = append(byRoot[r.Root], r.Path) + } + if len(byRoot[rootA]) != 1 || byRoot[rootA][0] != "a.md" { + t.Fatalf("rootA 事件錯:%v", byRoot[rootA]) + } + if len(byRoot[rootB]) != 1 || byRoot[rootB][0] != "b.md" { + t.Fatalf("rootB 事件錯:%v", byRoot[rootB]) + } +}