diff --git a/cmd/arcrun-app/.version-source.json b/cmd/arcrun-app/.version-source.json index bdac60c..d36416e 100644 --- a/cmd/arcrun-app/.version-source.json +++ b/cmd/arcrun-app/.version-source.json @@ -2,5 +2,6 @@ "v0.18.9": "8be9260eb542f527", "v0.18.10": "a440f044a849b7f2", "v0.18.11": "1538689e181444f6", - "v0.18.12": "ce37982f05d8cb5b" + "v0.18.12": "ce37982f05d8cb5b", + "v0.18.13": "fbf7342d0d0e27b4" } diff --git a/cmd/arcrun-app/app.go b/cmd/arcrun-app/app.go index 1ac0d9c..4bfe120 100644 --- a/cmd/arcrun-app/app.go +++ b/cmd/arcrun-app/app.go @@ -140,9 +140,39 @@ func loadCfg() (*directConfig, error) { return &directConfig{raw: map[string]any{}}, err } _ = json.Unmarshal(b, &c.raw) + // 🔴 自我修復:舊版存出來的 config 可能少了 collector 的必填欄位。 + // 只補進記憶體不夠——collector 讀的是**磁碟上那份**,所以要寫回去。 + if fillRequired(c.raw) { + if out, err := json.MarshalIndent(c.raw, "", " "); err == nil { + _ = os.WriteFile(configPath(), out, 0o600) + } + if m, ok := c.raw["manifest"].(string); ok { + c.Manifest = m + } + appLog("設定檔缺必填欄位,已自動補上 manifest=%v", c.raw["manifest"]) + } return c, nil } +// fillRequired 補上 collector 的必填欄位,回報有沒有真的補過。 +// +// 🔴 leo 2026-08-06 兩輪教訓: +// +// 第一輪:`saveCfg` 從來不寫 `manifest`(collector 必填)⇒ 全新安裝一啟動就 exit 2。 +// 第二輪(**我第一次修錯**):只在 saveCfg 補 ⇒ **已經存在的壞設定永遠修不好**—— +// App 開起來只是**讀** config 然後啟動引擎,saveCfg 根本沒被呼叫。 +// leo 的 v0.18.12 實測仍是同一句「缺必填欄位:manifest」,重試 30 次。 +// 他的 config 剛好只有 saveCfg 寫的那四個鍵 ⇒ 鐵證。 +// ⇒ 補必填要在**讀取時**做(升級路徑),存檔時也做(新建路徑),兩條都要。 +func fillRequired(raw map[string]any) bool { + changed := false + if v, ok := raw["manifest"].(string); !ok || strings.TrimSpace(v) == "" { + raw["manifest"] = filepath.Join(appDir(), "manifest.json") + changed = true + } + return changed +} + func saveCfg(c *directConfig) error { if c.raw == nil { c.raw = map[string]any{} @@ -162,9 +192,7 @@ func saveCfg(c *directConfig) error { // ⇒ 畫面在「看守中/沒有在跑」之間閃、加資料夾也沒反應。 // 為什麼開發機沒撞到:leo 的 Mac config 是舊版留下的、早就有這一欄 // ——**「我這台好好的」正是這個 bug 能活到封測的原因**。 - if v, ok := c.raw["manifest"].(string); !ok || strings.TrimSpace(v) == "" { - c.raw["manifest"] = filepath.Join(appDir(), "manifest.json") - } + fillRequired(c.raw) out, err := json.MarshalIndent(c.raw, "", " ") if err != nil { diff --git a/cmd/arcrun-app/upgrade_repair_test.go b/cmd/arcrun-app/upgrade_repair_test.go new file mode 100644 index 0000000..821fa28 --- /dev/null +++ b/cmd/arcrun-app/upgrade_repair_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// leo 2026-08-06 在 Windows 上真實壞掉的那份 config(逐字,只把值改成假的)。 +// 它剛好只有 saveCfg 會寫的四個鍵 —— 那就是「App 自己存出來、但少了必填欄位」的鐵證。 +const leoBrokenConfig = `{ + "accounts": [{"cypher_url":"https://x.workers.dev","namespace":"abc123","api_key":"abc123"}], + "extractor": "workers-ai", + "extractor_explicit": true, + "gemini_api_key": "" +}` + +// TestExistingBrokenConfigSelfHeals 釘住我**第一次修錯**的那條路: +// +// 只在 saveCfg 補必填欄位 ⇒ 全新安裝好了,但**已經存在的壞設定永遠修不好**, +// 因為 App 開起來只是「讀 config → 啟動引擎」,saveCfg 根本沒被呼叫。 +// leo 裝了 v0.18.12 仍看到同一句「缺必填欄位:manifest」、重試 30 次。 +// +// 所以這支測試從**磁碟上已存在的壞 config** 出發,而不是從 saveCfg 出發。 +func TestExistingBrokenConfigSelfHeals(t *testing.T) { + bin := filepath.Join(t.TempDir(), "arcrun-app-test") + if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil { + t.Fatalf("編不出執行檔:%v\n%s", err, out) + } + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + if err := os.MkdirAll(appDir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath(), []byte(leoBrokenConfig), 0o600); err != nil { + t.Fatal(err) + } + + // App 讀一次 config —— 這就是使用者「打開程式」會發生的事 + if _, err := loadCfg(); err != nil { + t.Fatalf("讀不了 config:%v", err) + } + + // 修復必須**寫回磁碟**:collector 是另一個行程,讀的是磁碟那份,不是記憶體 + raw, err := os.ReadFile(configPath()) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("修完不是合法 JSON:%v", err) + } + if s, _ := m["manifest"].(string); strings.TrimSpace(s) == "" { + t.Fatal("讀取後沒把 manifest 補進磁碟 ⇒ collector 仍會 exit 2(就是 leo v0.18.12 撞到的)") + } + + // 終極驗收:真的把執行檔當 collector 跑,不能再是 exit 2 + cmd := exec.Command(bin, collectorModeFlag, "direct", "--once", "--dry-run", "--config", configPath()) + cmd.Env = append(os.Environ(), "HOME="+home, "USERPROFILE="+home) + out, err := cmd.CombinedOutput() + code := 0 + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } + if code == 2 || strings.Contains(string(out), "缺必填欄位") { + t.Fatalf("舊 config 修復後同步引擎仍被拒(exit=%d):%s", code, out) + } +}