From 6bf0daa7869778ceef115cd12f9ba86f9ea3603a Mon Sep 17 00:00:00 2001 From: richblack Date: Wed, 5 Aug 2026 18:01:17 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E9=A6=96=E9=A0=81=E7=8B=80=E6=85=8B?= =?UTF-8?q?=E8=AA=AA=E8=AC=8A=EF=BC=8BDMG=20=E8=A3=9C=E4=B8=8A=E6=8B=96?= =?UTF-8?q?=E6=9B=B3=E7=89=88=E9=9D=A2=EF=BC=88leo=2008-05=20=E5=B0=81?= =?UTF-8?q?=E6=B8=AC=E5=9B=9E=E5=A0=B1=20=E2=91=A0=E2=91=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ② 首頁狀態是壞的 leo:「拖新的檔案進資料夾,完成本地萃取、上傳,但自始至終 daemon 的首頁都顯示 『有變動就自動開始』『等待中』,都顯示綠燈沒動,實際上已經做完了。」 兩個獨立的真兇: **A. 「同步中」判斷用錯依據(回歸)** t191 早就做好機制:collector 開工印 phase:"start"、跑完印 "done", supervisor 據此維護 StateSyncing。**換 Wails 時 App 沒接這條線**,改看 sync-now 訊號檔: ① 訊號檔只有手動按「立刻同步」才產生 ⇒ 拖檔進資料夾(自動觸發)整輪不亮燈 ② 就算手動按,collector 是**先刪檔再跑**(consumeSyncNowSignal)⇒ 真正在跑時檔早沒了 ⇒ 改讀 sup.Status().State == StateSyncing。**接回既有機制,不發明第三種判斷法。** **B. 做完的證據被下一輪抹掉** ExtractedOK/ExtractFailed 是**本輪**計數、每輪覆寫整份 status.json ⇒ 有產出那輪寫下 N,十幾秒後空轉的一輪把它蓋成 0 ⇒ 「上一輪 N 份」永遠空白、四步時間軸的綠燈只靠「跑過任何一輪」判斷=與實際進度脫鉤。 ⇒ 新增 last_activity_{at,ok,failed}:有產出記本輪、空轉沿用上一輪(CarryForwardActivity)。 首頁改顯示「幾點整理了幾份」,綠燈只在真的整理過東西時才亮。 ## ① Mac DMG 少了「看得出要拖」的版面 leo:「要跳出虛擬隨身碟,打開一個 finder 的視窗,**顯示 Arcrun 和 Application 的捷徑**, 用戶把 App 拖進 Application」。 原本 DMG 內容是對的(Arcrun.app+Applications 捷徑),但**沒設版面** ⇒ 預設清單視圖、位置隨機,看不出「要往右拖」。 ⇒ build-dmg.sh 改走 UDRW → Finder AppleScript 設大圖示/視窗大小/左右並排 → 轉 UDZO。 best-effort:無桌面工作階段時只警告不中止(版面是加分,不該讓打包掛掉)。 ⚠️ 真正讓 leo 拿到 zip 的原因不在這裡——是**線上 portal 還沒出貨**(見下)。 ## 驗(實測輸出) · collector 全測綠;新增 TestCarryForwardActivity 四子測(含「空轉不該抹掉上次成果」) · 掛載 dist/Arcrun-v0.18.5.dmg 實查: 內容剛好兩項(Arcrun.app + Applications→/Applications 捷徑) .DS_Store 6148 bytes(版面已存進去) Contents/MacOS/ 有 arcrun-app **和 arcrun-collector** CFBundleShortVersionString = 0.18.5(不是 1.0.0)/LSUIElement = true/codesign -v 通過 · 三支機械閘 check-cis/check-render/check-tray 全 PASS Co-Authored-By: Claude Opus 5 --- cmd/arcrun-app/app.go | 68 +++++++++++++++++++++++++++---------- cmd/arcrun-app/build-dmg.sh | 45 ++++++++++++++++++++++-- cmd/arcrun-app/supervise.go | 13 +++++++ direct.go | 8 ++++- sync_status.go | 29 ++++++++++++++++ sync_status_test.go | 43 +++++++++++++++++++++++ 6 files changed, 185 insertions(+), 21 deletions(-) diff --git a/cmd/arcrun-app/app.go b/cmd/arcrun-app/app.go index 656121d..cd6dec4 100644 --- a/cmd/arcrun-app/app.go +++ b/cmd/arcrun-app/app.go @@ -78,6 +78,11 @@ type syncStatus struct { ExtractFailed int `json:"extract_failed"` ExtractorOK bool `json:"extractor_ok"` ExtractorError string `json:"extractor_error,omitempty"` + // 「上次真的有做事」那一輪(2026-08-05)——ExtractedOK 是本輪計數、會被下一輪歸零, + // 只靠它顯示成果,使用者在同步完成 15 秒後就看不到任何證據(leo 實撞)。 + LastActivityAt string `json:"last_activity_at,omitempty"` + LastActivityOK int `json:"last_activity_ok"` + LastActivityFailed int `json:"last_activity_failed"` } // loadCfg 同時保留原始 map ⇒ 回寫時**不會弄丟我們沒宣告的欄位** @@ -164,21 +169,33 @@ func buildSteps(s syncStatus, syncing bool) []Step { {Title: "上傳到你的知識庫", State: ""}, } } + // 🔴 2026-08-05:這三步以前只要「跑過任何一輪」(LastSync 非空)就全標綠, + // 而 meta 用的是**本輪**計數(下一輪歸零)⇒ 綠燈與實際進度脫鉤、數字又永遠空白, + // 正是 leo 說的「都顯示綠燈沒動,實際上已經做完了」。 + // ⇒ 綠燈只在「真的有整理過東西」時才亮,且亮的是上次有產出那輪的數字+時間。 done := "" - if s.ExtractedOK > 0 || s.LastSync != "" { + if s.LastActivityOK > 0 || s.LastActivityFailed > 0 { done = "done" } + found := "等待中" + if done != "" { + if t, err := time.Parse(time.RFC3339, s.LastActivityAt); err == nil { + found = "上次 " + t.Local().Format("15:04") + } else { + found = "已處理" + } + } meta := "" - if s.ExtractedOK > 0 { - meta = fmt.Sprintf("上一輪 %d 份", s.ExtractedOK) + if s.LastActivityOK > 0 { + meta = fmt.Sprintf("上次 %d 份", s.LastActivityOK) } up := Step{Title: "上傳到你的知識庫", Meta: meta, State: done} - if s.ExtractFailed > 0 { - up.Meta = fmt.Sprintf("%s · ⚠ %d 份失敗", meta, s.ExtractFailed) + if s.LastActivityFailed > 0 { + up.Meta = strings.TrimPrefix(fmt.Sprintf("%s · ⚠ %d 份失敗", meta, s.LastActivityFailed), " · ") } return []Step{ watch, - {Title: "發現變化", Meta: "等待中", State: done}, + {Title: "發現變化", Meta: found, State: done}, {Title: "用 AI 整理成知識卡", State: done}, up, } @@ -232,14 +249,23 @@ func loadSyncStatus() syncStatus { // · collector 沒在跑 → 明說「同步引擎沒有在跑」,不要假裝在整理 // · 有訊號檔且引擎活著 → 才是真的「同步中」 // · 其餘 → 看守中 +// +// 🔴 2026-08-05 第二修(leo:「拖新檔進資料夾…自始至終都顯示『等待中』, +// +// 實際上已經做完了,這個 status 是壞的」)——t195 只補了「引擎活著」那半, +// 「同步中」仍靠 sync-now 訊號檔判斷,那是錯的判準,兩個理由: +// ① 訊號檔**只有手動按「立刻同步」才會產生**;leo 這次是拖檔進資料夾 +// (自動觸發),整輪從頭到尾沒有訊號檔 ⇒ 畫面永遠停在「看守中/等待中」。 +// ② 就算是手動按的,collector 是**先刪檔再跑**(consumeSyncNowSignal), +// 所以真正在跑的那段時間訊號檔早就不見了。 +// ⇒ 改用 t191 已經做好的機制:collector 開工印 phase:"start"、跑完印 "done", +// supervisor 據此維護 StateSyncing(supervisor.go)。**那條線本來就在, +// Wails 版換代時沒接上而已**——不要再自己發明第三種判斷法。 func describeStatus(s syncStatus) (syncing bool, big, sub string) { - alive := collectorAlive() - _, queued := os.Stat(syncNowSignal()) - - if !alive { + if !collectorAlive() { return false, "同步引擎沒有在跑", "資料夾不會被自動整理 ⇒ 請結束 Arcrun 再重新開啟" } - if queued == nil { + if collectorSyncing() { return true, "同步中… 正在讀檔並整理成知識卡", "請稍候,完成後會顯示整理了幾份" } if s.ExtractorError != "" && !s.ExtractorOK { @@ -247,13 +273,21 @@ func describeStatus(s syncStatus) (syncing bool, big, sub string) { } parts := []string{} if t, err := time.Parse(time.RFC3339, s.LastSync); err == nil { - parts = append(parts, "上次同步 "+t.Local().Format("15:04")) + parts = append(parts, "上次檢查 "+t.Local().Format("15:04")) } - if s.ExtractedOK > 0 { - parts = append(parts, fmt.Sprintf("已整理 %d 份", s.ExtractedOK)) - } - if s.ExtractFailed > 0 { - parts = append(parts, fmt.Sprintf("⚠ %d 份失敗", s.ExtractFailed)) + // 用「上次真的有做事」那輪的數字,不用本輪計數——後者每輪歸零, + // 會讓剛整理完的成果在十幾秒後從畫面上消失(就是 leo 撞到的那個「壞掉的 status」)。 + if s.LastActivityOK > 0 || s.LastActivityFailed > 0 { + when := "" + if t, err := time.Parse(time.RFC3339, s.LastActivityAt); err == nil { + when = t.Local().Format("15:04") + " " + } + if s.LastActivityOK > 0 { + parts = append(parts, fmt.Sprintf("%s已整理 %d 份", when, s.LastActivityOK)) + } + if s.LastActivityFailed > 0 { + parts = append(parts, fmt.Sprintf("⚠ %d 份失敗", s.LastActivityFailed)) + } } if len(parts) == 0 { return false, "看守中 · 資料夾有變動就會自動整理", "還沒有同步紀錄" diff --git a/cmd/arcrun-app/build-dmg.sh b/cmd/arcrun-app/build-dmg.sh index 4162883..22e4aef 100755 --- a/cmd/arcrun-app/build-dmg.sh +++ b/cmd/arcrun-app/build-dmg.sh @@ -22,12 +22,51 @@ echo "② 放 app + Applications 捷徑" ditto "$APP" "$STAGE/Arcrun.app" ln -s /Applications "$STAGE/Applications" -echo "③ 產 DMG(-fs HFS+:否則含空格/中文的名稱會被截斷)" +echo "③ 產可寫 DMG(-fs HFS+:否則含空格/中文的名稱會被截斷)" rm -f "$DMG" -hdiutil create -volname "Arcrun" -srcfolder "$STAGE" -fs HFS+ -ov -format UDZO "$DMG" >/dev/null +RW="$(mktemp -d)/rw.dmg" +hdiutil create -volname "Arcrun" -srcfolder "$STAGE" -fs HFS+ -ov -format UDRW "$RW" >/dev/null rm -rf "$(dirname "$STAGE")" +# ── ④ 排版面:讓「拖進去」這件事一眼可見 ──────────────────────────────────── +# 🔴 leo 2026-08-05:「要跳出虛擬隨身碟,打開一個 finder 的視窗,**顯示 Arcrun 和 +# Application 的捷徑**,用戶把 App 拖進 Application」。 +# 光是「裡面有兩個項目」不夠——預設是清單視圖、位置隨機,使用者看不出「要往右拖」。 +# ⇒ 設成大圖示、固定視窗大小、兩個 icon 左右並排。 +# best-effort:Finder 自動化在沒有桌面工作階段時(ssh/CI)會失敗, +# 失敗只警告不中止——版面是加分,DMG 本身照樣可用,不該讓打包整個掛掉。 +hdiutil attach "$RW" -noautoopen -quiet +if osascript <<'APPLESCRIPT' >/dev/null 2>&1 +tell application "Finder" + tell disk "Arcrun" + open + set current view of container window to icon view + set toolbar visible of container window to false + set statusbar visible of container window to false + set the bounds of container window to {200, 160, 800, 560} + set vopts to the icon view options of container window + set arrangement of vopts to not arranged + set icon size of vopts to 128 + set position of item "Arcrun.app" of container window to {150, 190} + set position of item "Applications" of container window to {450, 190} + update without registering applications + close + end tell +end tell +APPLESCRIPT +then + echo " ✅ 版面已設定(大圖示,Arcrun 在左、Applications 在右)" +else + echo " ⚠️ Finder 版面設定失敗(多半是沒有桌面工作階段)——DMG 仍可用,只是視圖是預設值" >&2 +fi +sync +hdiutil detach "/Volumes/Arcrun" -quiet + +echo "⑤ 壓成唯讀發佈檔" +hdiutil convert "$RW" -format UDZO -o "$DMG" >/dev/null +rm -rf "$(dirname "$RW")" + SIZE=$(ls -lh "$DMG" | awk '{print $5}') echo "✅ 完成:${DMG}(${SIZE})" echo -echo "🔬 使用者流程:開 DMG → 把 Arcrun 拖到 Applications → 從啟動台開啟" +echo "🔬 使用者流程:開 DMG → 把 Arcrun 拖到 Applications → 退出這個虛擬磁碟 → 從啟動台開啟" diff --git a/cmd/arcrun-app/supervise.go b/cmd/arcrun-app/supervise.go index ae3d977..3528757 100644 --- a/cmd/arcrun-app/supervise.go +++ b/cmd/arcrun-app/supervise.go @@ -144,3 +144,16 @@ func collectorAlive() bool { } return processRunning(collectorBinPath()) } + +// collectorSyncing 回報 collector 是不是**正在跑一輪**。 +// +// 🔴 2026-08-05:資料來源是 t191 就做好、上面這支 supervisor 一直在維護的狀態機—— +// collector 開工印 phase:"start"、跑完印 "done",supervisor 據此進出 StateSyncing。 +// 換 Wails 時 App 沒接這條線、改看 sync-now 訊號檔,於是「拖檔進資料夾」這種 +// 自動觸發的同步整輪都不會亮燈(訊號檔只有手動按鈕才產生,而且 collector 是先刪再跑)。 +func collectorSyncing() bool { + if sup == nil { + return false + } + return sup.Status().State == supervisor.StateSyncing +} diff --git a/direct.go b/direct.go index 835b3c3..a05806a 100644 --- a/direct.go +++ b/direct.go @@ -640,7 +640,13 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge break } } - if serr := SaveSyncStatus(StatusFilePath(cfg.Manifest), st); serr != nil { + // 🔴 2026-08-05(leo:「自始至終都顯示『等待中』…實際上已經做完了,這個 status 是壞的」): + // 本輪有產出就記成「最近一次有做事」;本輪沒事做則把上一輪的原樣帶下來, + // 不要讓「剛整理完 N 份」這個唯一的完成證據在 15 秒後被歸零抹掉。 + statusPath := StatusFilePath(cfg.Manifest) + prev, _ := LoadSyncStatus(statusPath) // 讀不到=零值,等同「沒有上一輪」 + CarryForwardActivity(prev, &st) + if serr := SaveSyncStatus(statusPath, st); serr != nil { fmt.Fprintf(os.Stderr, "status 寫入失敗(不擋看守):%v\n", serr) } } diff --git a/sync_status.go b/sync_status.go index f529694..6b1771c 100644 --- a/sync_status.go +++ b/sync_status.go @@ -32,6 +32,14 @@ type SyncStatus struct { Failures []ExtractFail `json:"failures,omitempty"` // 失敗清單(路徑+白話原因) ExtractorOK bool `json:"extractor_ok"` // 萃取器本身是否就緒(預檢,機器層級) ExtractorError string `json:"extractor_error,omitempty"` // 未就緒的白話原因 + // 🔴 最近一輪「真的有做事」的結果(2026-08-05,leo 實撞)。 + // ExtractedOK/ExtractFailed 是**本輪**計數、每輪覆寫 ⇒ 沒事做的那輪就歸零。 + // leo 拖檔進資料夾,萃取上傳都跑完了,但下一輪(15 秒後)把數字歸零 + // ⇒ 首頁「上一輪 N 份」永遠空白,看起來像從頭到尾什麼都沒發生。 + // ⇒ 另存一組「上次有產出的那輪」,沒事做的輪次原樣往下帶,不被清掉。 + LastActivityAt string `json:"last_activity_at,omitempty"` // RFC3339,上次有產出那輪的完成時間 + LastActivityOK int `json:"last_activity_ok"` // 那一輪成功幾份 + LastActivityFailed int `json:"last_activity_failed"` // 那一輪失敗幾份 // 頂層 cloud 欄位保留向後相容(單帳號時同時填頂層+AccountDetails) CloudVersion string `json:"cloud_version,omitempty"` // bundle_version(單帳號時填) CloudCheckOK bool `json:"cloud_check_ok"` // /health 可達才為 true @@ -59,6 +67,27 @@ func SaveSyncStatus(path string, s SyncStatus) error { return os.WriteFile(path, data, 0o644) } +// CarryForwardActivity 決定「最近一次有做事」那三個欄位的值。 +// +// 🔴 2026-08-05 leo 實撞:「拖新檔進資料夾,完成本地萃取、上傳,但自始至終 daemon 的 +// 首頁都顯示『等待中』…實際上已經做完了,這個 status 是壞的」。 +// 真兇:ExtractedOK/ExtractFailed 是**本輪**計數,每輪覆寫整個 status.json +// ⇒ 做完事的那輪寫下 N,下一輪(十幾秒後)沒事做就把它蓋成 0, +// 使用者看到的畫面永遠是「什麼都沒發生」。 +// +// 規則:本輪有產出 → 記本輪;本輪沒事做 → 原樣沿用上一輪的(不清空)。 +func CarryForwardActivity(prev SyncStatus, st *SyncStatus) { + if st.ExtractedOK > 0 || st.ExtractFailed > 0 { + st.LastActivityAt = st.LastSync + st.LastActivityOK = st.ExtractedOK + st.LastActivityFailed = st.ExtractFailed + return + } + st.LastActivityAt = prev.LastActivityAt + st.LastActivityOK = prev.LastActivityOK + st.LastActivityFailed = prev.LastActivityFailed +} + // LoadSyncStatus 讀取狀態檔;不存在或解析失敗回零值+error(托盤自行降級)。 func LoadSyncStatus(path string) (SyncStatus, error) { var s SyncStatus diff --git a/sync_status_test.go b/sync_status_test.go index 8a00a93..5618d99 100644 --- a/sync_status_test.go +++ b/sync_status_test.go @@ -225,3 +225,46 @@ func TestRunDirectOnceWritesStatusExtractorFail(t *testing.T) { t.Error("ExtractorError 應有白話說明") } } + +// TestCarryForwardActivity 鎖住「做完的證據不會被下一輪抹掉」(2026-08-05 leo 實撞的 status 假象)。 +// +// 症狀:拖檔進資料夾 → 萃取上傳都跑完了,但首頁自始至終顯示「等待中」、綠燈沒動。 +// 真兇:ExtractedOK 是本輪計數,每輪覆寫整份 status.json ⇒ 有產出那輪寫下 N, +// 十幾秒後沒事做的那輪把它蓋成 0,使用者永遠看不到自己剛做完的事。 +func TestCarryForwardActivity(t *testing.T) { + t.Run("本輪有產出→記本輪", func(t *testing.T) { + st := SyncStatus{LastSync: "2026-08-05T17:18:34+08:00", ExtractedOK: 3, ExtractFailed: 1} + CarryForwardActivity(SyncStatus{}, &st) + if st.LastActivityAt != st.LastSync || st.LastActivityOK != 3 || st.LastActivityFailed != 1 { + t.Fatalf("有產出的一輪應記成 last_activity,得到 %+v", st) + } + }) + + t.Run("本輪沒事做→沿用上一輪,不歸零", func(t *testing.T) { + prev := SyncStatus{LastActivityAt: "2026-08-05T17:18:34+08:00", LastActivityOK: 3, LastActivityFailed: 1} + st := SyncStatus{LastSync: "2026-08-05T17:36:38+08:00"} // 空轉的一輪 + CarryForwardActivity(prev, &st) + if st.LastActivityOK != 3 || st.LastActivityFailed != 1 { + t.Fatalf("空轉的一輪不該抹掉上次成果,得到 OK=%d Failed=%d", st.LastActivityOK, st.LastActivityFailed) + } + if st.LastActivityAt != prev.LastActivityAt { + t.Fatalf("時間也要沿用上一輪:want %q got %q", prev.LastActivityAt, st.LastActivityAt) + } + }) + + t.Run("只有失敗也算有做事", func(t *testing.T) { + st := SyncStatus{LastSync: "2026-08-05T18:00:00+08:00", ExtractFailed: 2} + CarryForwardActivity(SyncStatus{}, &st) + if st.LastActivityFailed != 2 || st.LastActivityAt == "" { + t.Fatalf("全失敗的一輪也要留痕(否則使用者不知道剛剛出過事),得到 %+v", st) + } + }) + + t.Run("從沒跑過→全空,不編造", func(t *testing.T) { + st := SyncStatus{LastSync: "2026-08-05T18:00:00+08:00"} + CarryForwardActivity(SyncStatus{}, &st) + if st.LastActivityAt != "" || st.LastActivityOK != 0 || st.LastActivityFailed != 0 { + t.Fatalf("沒有任何歷史時應保持空值,得到 %+v", st) + } + }) +}