t176 daemon:LLM 設定移回地端+托盤單一實例(leo 08-03 架構翻案)
leo 回報 Windows daemon 三症狀,查完 ①③ 同根,根在「雲端控制地端」這個設計。 【①③ 真兇】雲端 extractor_config 是**全租戶共用一把 KV** (arcrun:portal.ts:43 portalTenant = worker 層級變數,不分用戶), 任一處設了 claude → 所有人的 daemon 都收到 claude。沒裝 Claude Code 的機器 FindClaudeBin 失敗 → 每檔萃取 failed、一張卡都沒建;想去 portal 改回 gemini, checkbox 卻恆 disabled(claude_available 恆 false,因 daemon 從未實作 report-capabilities 回報 → daemon_caps KV 永遠空)⇒ 用戶自己解不開。 awindhon 實證:雲端同步成功、Gemini key 有效、零張卡,config.json extractor="claude"。 【leo 裁示】「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後 控制地端」「地端先限制 Gemini API Key 配合客戶要求」「雲端就是 Workers AI」。 本次(daemon 端): - addOrUpdateAccount 不再接受雲端下發的 extractor/gemini_api_key/llm_model, 只收連線欄位。t126「每帳號一份萃取設定」照舊保留——t126 修的是「存在哪一層」, 本次改的是「值從哪來」,兩者正交。 - 托盤新增「AI 設定…」:使用者自己填 Gemini API Key,寫本地 config 後立即生效。 - 萃取一律走 gemini:殘留的 extractor:"claude" 正規化為 gemma;claude 路退役。 ⚠️ 這不是「自動偵測有無 claude」(leo 07-27 已否決的 B 案),是整條路先不支援。 - 清掉隨之死亡的 claude_bin 回寫(死代碼=錯誤的環境信號)。 - 托盤單一實例(症狀②):pidfile + 跨平台 processAlive。 mac 之前不多開是借 macOS Launch Services 的巧合,Windows 沒有該層 ⇒ 每點一次多一個 icon。 Unix 用 signal 0(EPERM 也算活著,測試抓到的實際 bug)/Windows 用 OpenProcess+ExitCode。 測試:collector 全綠、tray 全綠。5 個原本用 claude stub 的測試改走**真實 gemma 路** (httptest 替身注入 gemmaBaseURL),不是改斷言充綠;t126②③ 兩案翻轉成 「雲端下發一律被忽略」的回歸守衛;新增 4 案 single-instance。 未送達:本 commit 只到 code,尚未打包出貨;雲端側(刪 portal AI 設定區塊、 extractor 下發、admin/extractor)未動,待部署授權。CP rag-beta 步驟仍為 ◐。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+70
-14
@@ -602,28 +602,25 @@ func addOrUpdateAccount(cfg *directConfig, r *daemonConfigResp) bool {
|
||||
if r.Config.InstanceName != "" {
|
||||
cfg.Accounts[i].InstanceName = r.Config.InstanceName
|
||||
}
|
||||
// t126:萃取設定寫帳號層,不覆蓋機器層
|
||||
if r.Config.Extractor != "" {
|
||||
cfg.Accounts[i].Extractor = r.Config.Extractor
|
||||
}
|
||||
if r.Config.GeminiAPIKey != "" {
|
||||
cfg.Accounts[i].GeminiAPIKey = r.Config.GeminiAPIKey
|
||||
}
|
||||
if r.Config.LLMModel != "" {
|
||||
cfg.Accounts[i].LLMModel = r.Config.LLMModel
|
||||
}
|
||||
// t176(leo 08-03 翻案):**不再接受雲端下發的 LLM 設定**。
|
||||
// 地端用哪個模型/哪把金鑰,一律由使用者在托盤「AI 設定…」自己填。
|
||||
// t126 的「每帳號一份萃取設定」照舊保留(欄位還在、讀取仍帳號層優先),
|
||||
// 這裡拔掉的只是「值**從雲端來**」這條來源——t126 修的是存在哪一層,本次改的是值從哪來。
|
||||
// 為什麼拔(08-03 實證):雲端 extractor_config 是**全租戶共用一把 KV**
|
||||
// (arcrun:portal.ts:43 portalTenant 為 worker 層級變數,不分用戶),
|
||||
// 任一處設了 claude → 所有人的 daemon 都收到;沒裝 Claude Code 的機器萃取全滅,
|
||||
// 而 portal 的 claude 勾選框又恆 disabled ⇒ 用戶自己解不開(awindhon 實證:零張卡)。
|
||||
// leo:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後控制地端」
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 新帳號:t126 萃取設定直接寫帳號層
|
||||
// 新帳號:只寫連線欄位。t176——LLM 設定不吃雲端下發(見上方同批註解),
|
||||
// 由使用者在托盤「AI 設定…」自己填;欄位留白時讀取端會繼承機器層。
|
||||
acc := accountCfg{
|
||||
InstanceName: r.Config.InstanceName,
|
||||
Email: r.Config.Email,
|
||||
CypherURL: strings.TrimSuffix(strings.TrimSpace(r.Config.CypherURL), "/"),
|
||||
Namespace: r.Config.Namespace,
|
||||
Extractor: r.Config.Extractor, // t126
|
||||
GeminiAPIKey: r.Config.GeminiAPIKey, // t126
|
||||
LLMModel: r.Config.LLMModel, // t126
|
||||
}
|
||||
if acc.APIKey == "" {
|
||||
acc.APIKey = acc.Namespace
|
||||
@@ -692,6 +689,15 @@ func accountDisplayName(acc accountCfg) string {
|
||||
}
|
||||
|
||||
func main() {
|
||||
// t176:一台機器只跑一個托盤(leo 08-03「點選多次後托盤產生多個 icon」)。
|
||||
// 必須在建 fyne app 之前擋——一旦 app 起來就會掛上第二個托盤 icon。
|
||||
ok, releaseInstance := acquireSingleInstance()
|
||||
if !ok {
|
||||
fmt.Fprintln(os.Stderr, "Arcrun RAG 已經在執行中(系統匣裡找找看),這次不重複開啟。")
|
||||
return
|
||||
}
|
||||
defer releaseInstance()
|
||||
|
||||
a := app.NewWithID("dev.arcrun.rag.tray")
|
||||
cfg := loadConfig()
|
||||
|
||||
@@ -729,6 +735,7 @@ func main() {
|
||||
// leo 原話:「貼上網址後說這不是個網址然後清空內容,**很糟糕**」。
|
||||
// ⇒ 改成帶入上次輸入(prefill)。密碼不帶回(打錯密碼時重打比較安全,也避免把密碼留在記憶體更久)。
|
||||
var showConnectWizard func()
|
||||
var showAISettings func() // t176:地端 AI 設定(Gemini 金鑰)
|
||||
var showConnectWizardWith func(prevURL, prevEmail string)
|
||||
showConnectWizard = func() { showConnectWizardWith("", "") }
|
||||
showConnectWizardWith = func(prevURL, prevEmail string) {
|
||||
@@ -802,6 +809,53 @@ func main() {
|
||||
d.Show()
|
||||
}
|
||||
|
||||
// t176(leo 08-03 架構翻案):AI 設定住在地端,不再由雲端下發。
|
||||
// 現階段**只支援 Gemini**(leo:「地端先限制 Gemini API Key 配合客戶要求」);
|
||||
// 之後要擴充成「任何用戶想用的模型」時,這裡加 provider 選單即可(欄位已預留 LLMModel)。
|
||||
// ⚠️ 這是「緊急先加上輸入位置」的版本——leo 已指出托盤下拉選單塞不下未來的設定量
|
||||
//(30+ 資料夾根本認不出),後續走本地 HTML 設定頁(像 Google Drive)。
|
||||
showAISettings = func() {
|
||||
keyEntry := widget.NewPasswordEntry()
|
||||
keyEntry.SetPlaceHolder("貼上你的 Gemini API Key")
|
||||
keyEntry.SetText(cfg.GeminiAPIKey) // 帶入現值,讓他看得到「已經設過了」
|
||||
hint := widget.NewLabel("")
|
||||
hint.Wrapping = fyne.TextWrapWord
|
||||
|
||||
form := container.NewVBox(
|
||||
widget.NewLabel("萃取用的 AI 設定"),
|
||||
widget.NewLabel("目前使用 Google Gemini。到 aistudio.google.com 免費申請金鑰,貼在下面:"),
|
||||
keyEntry, hint,
|
||||
)
|
||||
d := dialog.NewCustomConfirm("AI 設定", "儲存", "取消", form, func(ok bool) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(keyEntry.Text)
|
||||
if key == "" {
|
||||
dialog.ShowError(errors.New("金鑰是空的——請貼上 Gemini API Key"), win)
|
||||
return
|
||||
}
|
||||
// 機器層寫一份當預設;各帳號層一併寫,避免舊 config 殘留的空欄位繼承到別的值。
|
||||
cfg.GeminiAPIKey = key
|
||||
cfg.Extractor = "gemma"
|
||||
for i := range cfg.Accounts {
|
||||
cfg.Accounts[i].GeminiAPIKey = key
|
||||
cfg.Accounts[i].Extractor = "gemma"
|
||||
}
|
||||
if err := saveConfig(cfg); err != nil {
|
||||
dialog.ShowError(err, win)
|
||||
return
|
||||
}
|
||||
restartWatch() // 讓新金鑰立刻生效,不必等下一輪
|
||||
rebuildTray()
|
||||
dialog.ShowInformation("已儲存", "AI 設定已更新,接下來的同步會用這把金鑰萃取。", win)
|
||||
}, win)
|
||||
d.Resize(fyne.NewSize(460, 260))
|
||||
win.Show()
|
||||
win.RequestFocus()
|
||||
d.Show()
|
||||
}
|
||||
|
||||
addAction := func() {
|
||||
win.Show()
|
||||
win.RequestFocus()
|
||||
@@ -996,6 +1050,8 @@ func main() {
|
||||
syncNowItem, // t98:立刻同步
|
||||
// t104:「連上知識庫」改為「新增帳號」——精靈成功後 append 到 accounts,不換掉舊帳號
|
||||
fyne.NewMenuItem("+ 新增帳號…", func() { showConnectWizard() }),
|
||||
// t176:AI 設定移到地端(leo 08-03:「地端要用什麼模型就在 daemon 上輸入 API Key 設置」)
|
||||
fyne.NewMenuItem("AI 設定…", func() { showAISettings() }),
|
||||
fyne.NewMenuItemSeparator(),
|
||||
versionItem,
|
||||
checkUpdateItem, // t150:手動檢查更新(leo:「他去按一下檢查更新然後更新就好」)
|
||||
|
||||
@@ -511,72 +511,82 @@ func TestAccountEngineLabel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// t126②:addOrUpdateAccount 把 extractor/key 寫進帳號層,機器層不被覆蓋。
|
||||
func TestAddOrUpdateAccount_WritesExtractorToAccountLayer(t *testing.T) {
|
||||
// t176(翻轉自 t126②):新帳號也一樣——只收連線欄位,雲端下發的 LLM 設定一律不落地。
|
||||
// 帳號層留白時,讀取端會繼承機器層(=使用者在「AI 設定…」填的那把)。
|
||||
func TestAddOrUpdateAccount_NewAccountIgnoresRemoteLLMSettings(t *testing.T) {
|
||||
cfg := &directConfig{
|
||||
Extractor: "claude", // 機器層原有值
|
||||
GeminiAPIKey: "machine-key", // 機器層原有值
|
||||
Extractor: "gemma", // 機器層=使用者自己填的
|
||||
GeminiAPIKey: "machine-key",
|
||||
}
|
||||
resp := &daemonConfigResp{Success: true}
|
||||
resp.Config.CypherURL = "https://new.workers.dev"
|
||||
resp.Config.Namespace = "ns1"
|
||||
resp.Config.Extractor = "gemma"
|
||||
resp.Config.GeminiAPIKey = "account-key"
|
||||
resp.Config.LLMModel = "gemma-4-31b-it"
|
||||
resp.Config.Extractor = "claude" // 雲端想下發 claude
|
||||
resp.Config.GeminiAPIKey = "cloud-key" // 以及別把金鑰
|
||||
resp.Config.LLMModel = "some-cloud-model" //
|
||||
|
||||
isNew := addOrUpdateAccount(cfg, resp)
|
||||
if !isNew {
|
||||
if isNew := addOrUpdateAccount(cfg, resp); !isNew {
|
||||
t.Error("應為新帳號")
|
||||
}
|
||||
if len(cfg.Accounts) == 0 {
|
||||
t.Fatal("應新增帳號")
|
||||
}
|
||||
// 帳號層應有新值
|
||||
if cfg.Accounts[0].Extractor != "gemma" {
|
||||
t.Errorf("帳號層 Extractor 錯:got %q", cfg.Accounts[0].Extractor)
|
||||
// 連線欄位要寫進去(這條路徑本來的職責)
|
||||
if cfg.Accounts[0].Namespace != "ns1" || cfg.Accounts[0].CypherURL != "https://new.workers.dev" {
|
||||
t.Errorf("連線欄位應寫入,got ns=%q url=%q", cfg.Accounts[0].Namespace, cfg.Accounts[0].CypherURL)
|
||||
}
|
||||
if cfg.Accounts[0].GeminiAPIKey != "account-key" {
|
||||
t.Errorf("帳號層 GeminiAPIKey 錯:got %q", cfg.Accounts[0].GeminiAPIKey)
|
||||
// LLM 欄位一律留白=不吃雲端的
|
||||
if cfg.Accounts[0].Extractor != "" {
|
||||
t.Errorf("不該吃雲端下發的 extractor,got %q", cfg.Accounts[0].Extractor)
|
||||
}
|
||||
if cfg.Accounts[0].LLMModel != "gemma-4-31b-it" {
|
||||
t.Errorf("帳號層 LLMModel 錯:got %q", cfg.Accounts[0].LLMModel)
|
||||
if cfg.Accounts[0].GeminiAPIKey != "" {
|
||||
t.Errorf("不該吃雲端下發的金鑰,got %q", cfg.Accounts[0].GeminiAPIKey)
|
||||
}
|
||||
// 機器層不應被覆蓋
|
||||
if cfg.Extractor != "claude" {
|
||||
t.Errorf("機器層 Extractor 不應被覆蓋,got %q", cfg.Extractor)
|
||||
if cfg.Accounts[0].LLMModel != "" {
|
||||
t.Errorf("不該吃雲端下發的模型,got %q", cfg.Accounts[0].LLMModel)
|
||||
}
|
||||
if cfg.GeminiAPIKey != "machine-key" {
|
||||
t.Errorf("機器層 GeminiAPIKey 不應被覆蓋,got %q", cfg.GeminiAPIKey)
|
||||
// 機器層(使用者自己填的)不受影響
|
||||
if cfg.Extractor != "gemma" || cfg.GeminiAPIKey != "machine-key" {
|
||||
t.Errorf("機器層不該被雲端動到,got extractor=%q key=%q", cfg.Extractor, cfg.GeminiAPIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// t126③:同 host 更新帳號時 extractor 也寫帳號層。
|
||||
func TestAddOrUpdateAccount_UpdateWritesExtractorToAccountLayer(t *testing.T) {
|
||||
// t176(翻轉自 t126③):**雲端下發的 LLM 設定一律被忽略**。
|
||||
// leo 08-03:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後控制地端」。
|
||||
// 這條是回歸守衛——雲端 extractor_config 是全租戶共用一把 KV,任一處設了 claude
|
||||
// 會讓所有沒裝 Claude Code 的機器萃取全滅(awindhon 08-03 實證:零張卡)。
|
||||
func TestAddOrUpdateAccount_IgnoresRemoteLLMSettings(t *testing.T) {
|
||||
cfg := &directConfig{
|
||||
Extractor: "claude", // 機器層
|
||||
Extractor: "gemma", // 機器層=使用者自己在「AI 設定…」填的
|
||||
GeminiAPIKey: "my-own-key", //
|
||||
Accounts: []accountCfg{{
|
||||
CypherURL: "https://inst.workers.dev",
|
||||
Namespace: "ns1",
|
||||
CypherURL: "https://inst.workers.dev",
|
||||
Namespace: "ns1",
|
||||
Extractor: "gemma",
|
||||
GeminiAPIKey: "my-own-key",
|
||||
}},
|
||||
}
|
||||
// 雲端試圖下發 claude + 別把金鑰——全部都不該生效
|
||||
resp := &daemonConfigResp{Success: true}
|
||||
resp.Config.CypherURL = "https://inst.workers.dev"
|
||||
resp.Config.Namespace = "ns1"
|
||||
resp.Config.Extractor = "gemma"
|
||||
resp.Config.GeminiAPIKey = "per-account-key"
|
||||
resp.Config.Extractor = "claude"
|
||||
resp.Config.GeminiAPIKey = "cloud-pushed-key"
|
||||
|
||||
isNew := addOrUpdateAccount(cfg, resp)
|
||||
if isNew {
|
||||
if isNew := addOrUpdateAccount(cfg, resp); isNew {
|
||||
t.Error("同 host 應為更新(回 false)")
|
||||
}
|
||||
if cfg.Accounts[0].Extractor != "gemma" {
|
||||
t.Errorf("帳號層 Extractor 應更新,got %q", cfg.Accounts[0].Extractor)
|
||||
t.Errorf("雲端下發的 extractor 不該覆蓋本地設定,got %q", cfg.Accounts[0].Extractor)
|
||||
}
|
||||
if cfg.Accounts[0].GeminiAPIKey != "per-account-key" {
|
||||
t.Errorf("帳號層 GeminiAPIKey 應更新,got %q", cfg.Accounts[0].GeminiAPIKey)
|
||||
if cfg.Accounts[0].GeminiAPIKey != "my-own-key" {
|
||||
t.Errorf("雲端下發的金鑰不該覆蓋本地金鑰,got %q", cfg.Accounts[0].GeminiAPIKey)
|
||||
}
|
||||
// 機器層不變
|
||||
if cfg.Extractor != "claude" {
|
||||
t.Errorf("機器層 Extractor 不應被覆蓋,got %q", cfg.Extractor)
|
||||
if cfg.Extractor != "gemma" || cfg.GeminiAPIKey != "my-own-key" {
|
||||
t.Errorf("機器層不該被雲端動到,got extractor=%q key=%q", cfg.Extractor, cfg.GeminiAPIKey)
|
||||
}
|
||||
// 連線欄位仍要更新(這條路徑本來的職責)
|
||||
if cfg.Accounts[0].Namespace != "ns1" {
|
||||
t.Errorf("連線欄位仍應更新,got namespace=%q", cfg.Accounts[0].Namespace)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// processAlive 用 signal 0 探測:不真的送訊號,只做「這個 pid 現在存在嗎」的檢查。
|
||||
//
|
||||
// ⚠️ EPERM 也算活著:行程存在但屬於別的使用者(權限不足送訊號)。
|
||||
// 只有 ESRCH(查無此行程)才是真的死了。把 EPERM 當死會讓鎖被錯誤接管 ⇒ 又變成多開。
|
||||
func processAlive(pid int) bool {
|
||||
p, err := os.FindProcess(pid) // Unix 上這一步不會失敗,真正的判定在 Signal
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
err = p.Signal(syscall.Signal(0))
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, syscall.EPERM)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
// processAlive 開啟該 pid 的行程控制代碼並讀退出碼;
|
||||
// STILL_ACTIVE(259) 代表還在跑。Windows 沒有 signal 0 這種探測法。
|
||||
func processAlive(pid int) bool {
|
||||
const stillActive = 259
|
||||
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
||||
if err != nil {
|
||||
return false // 開不起來=行程不存在(或已無權限,一律當作不存在,寧可放行不要擋住啟動)
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
var code uint32
|
||||
if err := windows.GetExitCodeProcess(h, &code); err != nil {
|
||||
return false
|
||||
}
|
||||
return code == stillActive
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// singleinstance.go — 一台機器只跑一個托盤(t176,leo 08-03 回報「點選多次後托盤產生多個 icon」)。
|
||||
//
|
||||
// 為什麼要自己做:托盤程式**從來沒有任何** single-instance 機制(無 mutex/lockfile/pidfile)。
|
||||
// mac 上看起來不會重複開,是**借了 macOS Launch Services 對同 bundle ID .app 的內建去重**
|
||||
// (`open` 喚回既有行程),不是這支程式自己做的;Windows 的 .exe 沒有這層,
|
||||
// 於是每雙擊一次就真的多一個行程、多一個托盤 icon。
|
||||
//
|
||||
// 作法:在 ~/.arcrun-rag/tray.lock 寫入自己的 pid,啟動時檢查該 pid 是否還活著。
|
||||
// 選 pidfile 而非 OS 專屬鎖(Windows CreateMutex/unix flock)的理由:
|
||||
// 產物是 CGO_ENABLED=0 的純 stdlib 單一執行檔(見 build-*.sh),跨平台一份實作最不容易漂移。
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// trayLockPath 回傳鎖檔路徑(與 config/manifest 同一個 app 目錄)。可注入(測試用)。
|
||||
var trayLockPath = func() string { return filepath.Join(appDir(), "tray.lock") }
|
||||
|
||||
// acquireSingleInstance 嘗試取得「本機唯一托盤」的鎖。
|
||||
// 回傳 false 代表已經有另一個托盤在跑(呼叫端應該直接退出,不要再建第二個托盤 icon)。
|
||||
//
|
||||
// 誠實限制:pid 在行程結束後可能被作業系統重用,極端情況下會誤判成「還在跑」。
|
||||
// 這種情況下使用者刪掉 tray.lock 就能恢復,比「每點一次多一個 icon」好得多。
|
||||
func acquireSingleInstance() (ok bool, release func()) {
|
||||
path := trayLockPath()
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if pid, perr := strconv.Atoi(strings.TrimSpace(string(data))); perr == nil && pid > 0 && pid != os.Getpid() {
|
||||
if processAlive(pid) {
|
||||
return false, func() {}
|
||||
}
|
||||
// pid 已死=上次沒有正常結束(當機/強制關閉)留下的殘檔,直接接管。
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
// 建不了目錄就不擋啟動——寧可容忍重複開,也不要讓使用者完全打不開。
|
||||
return true, func() {}
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(fmt.Sprint(os.Getpid())), 0o644); err != nil {
|
||||
return true, func() {}
|
||||
}
|
||||
return true, func() { _ = os.Remove(path) }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// singleinstance_test.go — t176:一台機器只跑一個托盤(leo 08-03「點多次產生多個 icon」)。
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withTempLock 把鎖檔導到暫存目錄,避免測試碰到使用者真正的 ~/.arcrun-rag。
|
||||
func withTempLock(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "tray.lock")
|
||||
orig := trayLockPath
|
||||
trayLockPath = func() string { return path }
|
||||
t.Cleanup(func() { trayLockPath = orig })
|
||||
return path
|
||||
}
|
||||
|
||||
// 第一個實例拿得到鎖;release 後鎖檔消失。
|
||||
func TestSingleInstanceFirstAcquires(t *testing.T) {
|
||||
path := withTempLock(t)
|
||||
|
||||
ok, release := acquireSingleInstance()
|
||||
if !ok {
|
||||
t.Fatal("第一個實例應該拿得到鎖")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("鎖檔應被建立:%v", err)
|
||||
}
|
||||
if got, _ := strconv.Atoi(string(data)); got != os.Getpid() {
|
||||
t.Errorf("鎖檔應寫入自己的 pid,got %q want %d", data, os.Getpid())
|
||||
}
|
||||
|
||||
release()
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Error("release 後鎖檔應被刪除(否則下次啟動要靠 pid 檢查兜底)")
|
||||
}
|
||||
}
|
||||
|
||||
// 鎖檔記著一個「還活著」的 pid(就用自己的父流程:本測試行程自己)→ 第二個實例被擋。
|
||||
// 這正是 leo 撞到的情境:點第二次不該再開一個托盤。
|
||||
func TestSingleInstanceSecondBlocked(t *testing.T) {
|
||||
path := withTempLock(t)
|
||||
// 寫入一個確定活著、且不等於自己的 pid:用 pid 1(init/launchd,任何系統上都在跑)。
|
||||
if err := os.WriteFile(path, []byte("1"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, _ := acquireSingleInstance(); ok {
|
||||
t.Error("已有活著的實例時,第二個應該被擋下(不然就會多一個托盤 icon)")
|
||||
}
|
||||
}
|
||||
|
||||
// 殘留鎖檔(上次當機留下、pid 已死)不應永久卡死啟動——要能接管。
|
||||
func TestSingleInstanceStaleLockTakenOver(t *testing.T) {
|
||||
path := withTempLock(t)
|
||||
// 挑一個幾乎不可能存在的 pid:先開一個行程再等它結束太慢,直接用超大值。
|
||||
if err := os.WriteFile(path, []byte("4194303"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok, release := acquireSingleInstance()
|
||||
if !ok {
|
||||
t.Fatal("殘留鎖檔(pid 已死)應可接管,否則使用者永遠打不開")
|
||||
}
|
||||
defer release()
|
||||
data, _ := os.ReadFile(path)
|
||||
if got, _ := strconv.Atoi(string(data)); got != os.Getpid() {
|
||||
t.Errorf("接管後鎖檔應改寫成自己的 pid,got %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
// 壞掉的鎖檔內容(非數字)不應擋住啟動。
|
||||
func TestSingleInstanceGarbageLock(t *testing.T) {
|
||||
path := withTempLock(t)
|
||||
if err := os.WriteFile(path, []byte("not-a-pid"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok, release := acquireSingleInstance()
|
||||
if !ok {
|
||||
t.Fatal("鎖檔內容壞掉時應放行(寧可容忍重複開,也不要讓人完全打不開)")
|
||||
}
|
||||
release()
|
||||
}
|
||||
@@ -432,15 +432,14 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
// t92-②:預檢 extractor(機器層級),有 fallback 路徑時就地更新 cfg.ClaudeBin。
|
||||
extractorOK := true
|
||||
extractorError := ""
|
||||
// t176(leo 08-03 拍板):**地端先只支援 Gemini**(「地端先限制 Gemini API Key 配合客戶要求」)。
|
||||
// 殘留的 extractor:"claude"(雲端舊版下發、或舊 config 殘留)一律當 gemma 處理。
|
||||
// 這不是「自動偵測有無 claude」(那是 leo 07-27 已否決的 B 案,見 daemon-beta/tasks.md:641),
|
||||
// 而是「claude 路整條先不支援」——之後要裝回來,把這段拿掉即可。
|
||||
if cfg.Extractor == "claude" {
|
||||
resolved, ferr := FindClaudeBin(cfg.ClaudeBin)
|
||||
if ferr != nil {
|
||||
extractorOK = false
|
||||
extractorError = "找不到 Claude 指令——請確認 Claude Code 已安裝,或改用 Gemma 萃取路"
|
||||
} else if resolved != cfg.ClaudeBin {
|
||||
cfg.ClaudeBin = resolved // in-memory 回寫;runDirect 偵到變化才存磁碟
|
||||
}
|
||||
} else if cfg.Extractor == "gemma" {
|
||||
cfg.Extractor = "gemma"
|
||||
}
|
||||
if cfg.Extractor == "gemma" {
|
||||
if strings.TrimSpace(cfg.GeminiAPIKey) == "" {
|
||||
extractorOK = false
|
||||
extractorError = "金鑰是空的——請在設定裡輸入 Gemini API Key"
|
||||
@@ -561,10 +560,8 @@ func shortError(msg string) string {
|
||||
func CheckExtractor(cfg *DirectConfig) (ok bool, errMsg string) {
|
||||
switch cfg.Extractor {
|
||||
case "claude":
|
||||
if _, err := FindClaudeBin(cfg.ClaudeBin); err != nil {
|
||||
return false, "找不到 Claude 指令——請確認 Claude Code 已安裝,或改用 Gemma 萃取路"
|
||||
}
|
||||
return true, ""
|
||||
// t176:claude 先不支援,等同 gemma(與 RunDirectOnce 的正規化保持一致,避免兩處漂移)。
|
||||
fallthrough
|
||||
case "gemma":
|
||||
if strings.TrimSpace(cfg.GeminiAPIKey) == "" {
|
||||
return false, "金鑰是空的——請在設定裡輸入 Gemini API Key"
|
||||
@@ -642,10 +639,12 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
|
||||
var cards []string
|
||||
var xerr error
|
||||
switch cfg.Extractor {
|
||||
case "claude":
|
||||
cards, xerr = ExtractWithClaude(cfg.ClaudeBin, absRoot, ev.Path)
|
||||
case "gemma":
|
||||
cards, xerr = ExtractWithGemma(cfg.GeminiAPIKey, cfg.LLMModel, absRoot, ev.Path)
|
||||
default:
|
||||
// t176:claude 路先不支援(RunDirectOnce 開頭已把 "claude" 正規化為 "gemma")。
|
||||
// 走到這裡代表 config 有沒見過的值——誠實報錯,不要靜默跳過(禁假綠)。
|
||||
xerr = fmt.Errorf("不支援的萃取方式 %q(目前只支援 Gemini)", cfg.Extractor)
|
||||
}
|
||||
if xerr != nil {
|
||||
res.Status, res.Error = "failed", "本地萃取失敗:"+xerr.Error()
|
||||
@@ -784,8 +783,9 @@ func runDirect(args []string) int {
|
||||
return 2
|
||||
}
|
||||
|
||||
// t92:第一輪若 claude 找到了 fallback 路徑,把更新後的 claude_bin 存回 config 檔(下次直達)。
|
||||
origClaudeBin := cfg.ClaudeBin
|
||||
// t176:t92 的 claude_bin 回寫已隨 claude 萃取路一併退役——RunDirectOnce 不再解析
|
||||
// claude 執行檔,cfg.ClaudeBin 不會被改寫,故沒有東西需要回寫。
|
||||
// (留著空轉的回寫邏輯會讓未來的人以為 claude 路還活著=錯誤的環境信號。)
|
||||
|
||||
runOne := func() int {
|
||||
results, exit, _ := RunDirectOnce(cfg, *dryRun)
|
||||
@@ -798,16 +798,6 @@ func runDirect(args []string) int {
|
||||
return exit
|
||||
}
|
||||
|
||||
// claude_bin 回寫:只在找到 fallback 路徑時才存(避免頻繁寫磁碟)
|
||||
persistClaudeBinIfChanged := func() {
|
||||
if cfg.ClaudeBin != origClaudeBin && cfg.ClaudeBin != "" && *configPath != "" {
|
||||
if serr := saveDirectConfig(*configPath, cfg); serr != nil {
|
||||
fmt.Fprintf(os.Stderr, "claude_bin 回寫 config 失敗(不擋看守):%v\n", serr)
|
||||
} else {
|
||||
origClaudeBin = cfg.ClaudeBin // 只存一次
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 四步定稿第 1 步:daemon 代裝 template——常駐看守前確保每根都鋪好(冪等,不覆寫既有檔)。
|
||||
// dry-run/--once 測試情境不代裝(不留副作用),由 template-install 子命令顯式做。
|
||||
@@ -825,9 +815,7 @@ func runDirect(args []string) int {
|
||||
}
|
||||
|
||||
if *once {
|
||||
code := runOne()
|
||||
persistClaudeBinIfChanged()
|
||||
return code
|
||||
return runOne()
|
||||
}
|
||||
// 常駐輪詢:純 stdlib,跨平台。首輪立即跑。
|
||||
// t98:每 1 秒檢查一次訊號檔(SyncNowSignalPath),命中即立刻跑一輪並刪檔;
|
||||
@@ -836,7 +824,6 @@ func runDirect(args []string) int {
|
||||
fmt.Fprintf(os.Stderr, "collector direct daemon 啟動:監看 %s → %s(每 %ds 掃一輪)\n",
|
||||
strings.Join(cfg.Folders(), "、"), cfg.triggerURL(cfg.IngestWF), cfg.PollSec)
|
||||
runOne()
|
||||
persistClaudeBinIfChanged() // 第一輪後立即回寫(下次重啟直達)
|
||||
|
||||
signalPath := SyncNowSignalPath(cfg.Manifest)
|
||||
pollInterval := time.Duration(cfg.PollSec) * time.Second
|
||||
|
||||
+43
-41
@@ -12,7 +12,29 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 完整鏈(claude stub 版):丟原稿 → 萃卡落地本地 → 只有「卡片」被 POST 到 rag_ingest_card
|
||||
// cardFixture 組出一張最簡知識卡(含一條三元組關聯)。
|
||||
// 三元組的分隔符在原始碼裡用組字串的方式產生,避免被 arcrun 意圖 guard 誤判成工作流的邊。
|
||||
func cardFixture(subject, object string) string {
|
||||
sep := strings.Repeat(">", 2)
|
||||
return "# " + subject + "\n## 一句話定義\n測試卡\n## 關聯\n- " +
|
||||
subject + " " + sep + " 屬於 " + sep + " " + object + "\n"
|
||||
}
|
||||
|
||||
// gemmaCardStub 讓 Gemini 替身回傳一張以 pageName 命名的卡片(t176 起產品只走 gemma 路,
|
||||
// 測試也跟著走真實路徑——不再用 claude stub,否則測的是產品走不到的分支=假綠)。
|
||||
// 卡片內容照 gemmaPrompt 的契約:含「## 一句話定義」與「## 關聯」。
|
||||
func gemmaCardStub(t *testing.T, cardBody string) func() {
|
||||
t.Helper()
|
||||
return gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"candidates": []map[string]any{{
|
||||
"content": map[string]any{"parts": []map[string]any{{"text": cardBody}}},
|
||||
}},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 完整鏈(gemma 替身版):丟原稿 → 萃卡落地本地 → 只有「卡片」被 POST 到 rag_ingest_card
|
||||
// → 原文從未離開本機 → manifest 標 ingested(下一輪不重送)。
|
||||
func TestDirectExtractorModeE2E(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
@@ -34,19 +56,14 @@ func TestDirectExtractorModeE2E(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// stub claude:把原稿萃成卡(模擬 /wiki-capture 行為)
|
||||
stubDir := t.TempDir()
|
||||
stub := filepath.Join(stubDir, "claude")
|
||||
script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# 報銷規則\\n## 一句話定義\\n測試卡\\n## 關聯\\n- 報銷規則 >> 屬於 >> 財務\\n' > 'system-dev/wiki/cards/報銷規則.md'\n"
|
||||
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Gemini 替身:把原稿萃成卡
|
||||
defer gemmaCardStub(t, cardFixture("報銷規則", "財務"))()
|
||||
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||||
Library: "kb", Extractor: "claude", ClaudeBin: stub,
|
||||
Library: "kb", Extractor: "gemma", GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
@@ -102,19 +119,14 @@ func TestDirectExtractorRemovedClearsLocalCard(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// stub claude:萃卡落地
|
||||
stubDir := t.TempDir()
|
||||
stub := filepath.Join(stubDir, "claude")
|
||||
script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# 報銷規則\\n## 一句話定義\\n測試卡\\n' > 'system-dev/wiki/cards/報銷規則.md'\n"
|
||||
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Gemini 替身:萃卡落地
|
||||
defer gemmaCardStub(t, "# 報銷規則\n## 一句話定義\n測試卡\n")()
|
||||
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||||
Library: "kb", Extractor: "claude", ClaudeBin: stub,
|
||||
Library: "kb", Extractor: "gemma", GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", RemovedWF: "rag_takedown_direct",
|
||||
// 單檔刪除=removed ratio 100%,預設 0.4 防呆會壓下事件;本測試聚焦下架路,放寬到 1.0
|
||||
//(1 > 1.0×1 為 false → 事件放行)。
|
||||
@@ -162,17 +174,12 @@ func TestDirectExtractorRemovedNoLocalCardOK(t *testing.T) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
stubDir := t.TempDir()
|
||||
stub := filepath.Join(stubDir, "claude")
|
||||
script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# a\\n## 一句話定義\\n卡\\n' > system-dev/wiki/cards/a.md\n"
|
||||
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer gemmaCardStub(t, "# a\n## 一句話定義\n卡\n")()
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||||
Extractor: "claude", ClaudeBin: stub,
|
||||
Extractor: "gemma", GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", RemovedWF: "rag_takedown_direct",
|
||||
MaxRemoved: 1.0,
|
||||
}
|
||||
@@ -198,16 +205,16 @@ func TestDirectExtractorFailKeepsRetry(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(root, "a.md"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stubDir := t.TempDir()
|
||||
stub := filepath.Join(stubDir, "claude")
|
||||
if err := os.WriteFile(stub, []byte("#!/bin/sh\nexit 3\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Gemini 替身回 500=萃取失敗(真實失敗模式:模型端出錯)
|
||||
defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"boom"}`))
|
||||
})()
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
CypherURL: "https://x.example", Namespace: "demo", APIKey: "demo",
|
||||
Extractor: "claude", ClaudeBin: stub, MaxRemoved: DefaultMaxRemovedRatio,
|
||||
Extractor: "gemma", GeminiAPIKey: "k-test", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
if exit != 1 || len(results) != 1 || results[0].Status != "failed" {
|
||||
@@ -228,13 +235,8 @@ func TestMultiAccountInheritsExtractor(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// stub claude:輸出一張最簡卡片
|
||||
stubDir := t.TempDir()
|
||||
stub := filepath.Join(stubDir, "claude")
|
||||
script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# doc\\n## 定義\\n測試\\n## 關聯\\n- doc >> 屬於 >> kb\\n' > 'system-dev/wiki/cards/doc.md'\n"
|
||||
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Gemini 替身:輸出一張最簡卡片
|
||||
defer gemmaCardStub(t, cardFixture("doc", "kb"))()
|
||||
|
||||
var hitCard, hitDirect bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -247,12 +249,12 @@ func TestMultiAccountInheritsExtractor(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 機器層有 Extractor+ClaudeBin;帳號層 AccountConfig 無這些欄位(正是 t108 場景)
|
||||
// 機器層有 Extractor+GeminiAPIKey;帳號層 AccountConfig 無這些欄位(正是 t108 場景)
|
||||
cfg := &DirectConfig{
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
Library: "kb",
|
||||
Extractor: "claude",
|
||||
ClaudeBin: stub,
|
||||
Extractor: "gemma",
|
||||
GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card",
|
||||
IngestWF: "rag_ingest_direct",
|
||||
RemovedWF: "rag_takedown_direct",
|
||||
@@ -272,7 +274,7 @@ func TestMultiAccountInheritsExtractor(t *testing.T) {
|
||||
t.Error("不應打 rag_ingest_direct(原文不出機,違反四步定稿)")
|
||||
}
|
||||
if !hitCard {
|
||||
t.Error("應打 rag_ingest_card(機器層 extractor=claude 應被帳號繼承)")
|
||||
t.Error("應打 rag_ingest_card(機器層 extractor=gemma 應被帳號繼承)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-11
@@ -163,24 +163,16 @@ func TestSyncNowSignalPath(t *testing.T) {
|
||||
// TestRunDirectOnceWritesStatus:RunDirectOnce 完成後應在 manifest 同目錄寫出 status.json。
|
||||
// 使用空資料夾(無事件)確保不觸發 HTTP,只驗 extractor 預檢結果與 LastSync。
|
||||
func TestRunDirectOnceWritesStatus(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("stub 腳本測試僅跑 Unix")
|
||||
}
|
||||
// 建可執行的假 claude stub
|
||||
stubDir := t.TempDir()
|
||||
stub := filepath.Join(stubDir, "claude")
|
||||
if err := os.WriteFile(stub, []byte("#!/bin/sh\necho ok"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 空資料夾 → 零事件 → 無 HTTP 呼叫
|
||||
root := t.TempDir()
|
||||
manifestDir := t.TempDir()
|
||||
// t176:extractor 寫 "claude" 也會被正規化成 gemma(claude 路已不支援),
|
||||
// 故預檢看的是「Gemini 金鑰有沒有填」——這裡填了,ExtractorOK 應為 true。
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
Manifest: filepath.Join(manifestDir, "manifest.json"),
|
||||
CypherURL: "https://unused.example", Namespace: "demo",
|
||||
Extractor: "claude", ClaudeBin: stub,
|
||||
Extractor: "claude", GeminiAPIKey: "k-test",
|
||||
MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
RunDirectOnce(cfg, false)
|
||||
|
||||
Reference in New Issue
Block a user