Files
arcrun-collector/trigger.go
T
Leo 4d3a6a09a6 v0.18.9:collector 併進同一支執行檔——磁碟上不再攤出第二支 exe
leo 08-06 裁決:「不要兩支,寫成一支檔案」。

## 為什麼
v0.18.7-8 的「單一 exe」其實是**一支包著另一支**:collector.exe 被 go:embed
進 Arcrun.exe,執行時攤到 ~/.arcrun-rag/bin/ 再跑。
那正是防毒軟體眼中的 dropper 特徵 —— 封測者實撞
`Trojan:Win32/Sabsik.FL.A!ml`,檔案當場被隔離、自動刪除。

⚠️ 誠實界定:`!ml` 結尾=**機器學習判定**,Sabsik 是最常見的通用誤判家族,
   主因是「未簽章+下載次數少」,**不是**特別指向 dropper 行為。
   所以本次改動**不保證**解除誤判——真正的解是上架 MS Store(微軟簽章)。
   但「執行時把第二支 PE 寫到磁碟再執行」本來就該拿掉,這是對的方向且順手變小。

## 怎麼做
- `collector/` 39 個檔 `package main` → `package collector`,`main()` → 匯出的 `Run(args) int`
- 新增 `collector/cmd/collector/`(薄殼 CLI,讓單獨跑 collector 這條路仍可用)
- App 直接 import 該套件;`main()` 第一件事就判 `--collector`,是的話走 `collector.Run` 不碰 GUI
- `supervisor` 加 `ArgPrefix`,App 把 `BinPath` 指向 `os.Executable()` 自己
- 刪掉 `bundled_collector_{windows,other}.go`(embed + 攤檔那套)
- 三支打包腳本不再編/複製第二支;版本注入同時打到兩個 package
- build-win.sh 的機械閘改成**直接問它**:`--collector --version` 回得出版本才放行
  (舊閘是比大小,只能證明「有 embed」,證明不了「分派是對的」)

## 驗(真機實跑)
· `.app/Contents/MacOS/` 只有 **一個** 執行檔(原本兩個)
· 跑起來兩個行程是**同一個 exe**:
    …/MacOS/arcrun-app
    …/MacOS/arcrun-app --collector direct --config …
· `~/.arcrun-rag/bin` **不存在**(沒有任何東西被攤出來)
· 端到端:丟檔進看守資料夾 → collector.log `"status":"ingested","http_status":200`
· `lsappinfo` 仍是 `type="UIElement"`、`Version="0.18.9"`
· collector 39 檔測試全過;app 測試過;go vet 全綠;mac + windows 交叉編譯皆過
· 單檔 26MB → 22MB(不再夾帶第二份完整程式)
2026-08-06 16:00:47 +08:00

129 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// trigger.go — 把一輪掃描的 collector-trigger.v1 payload POST 到 arcrun named-webhook
// SDD ingest-hash-trigger task 4:觸發鏈從 Gitea push webhook 改為 collector 直打)。
//
// 目標端點=arcrun 原生 named-webhook 觸發機制(design 鐵律段明言保留):
//
// POST {cypher}/webhooks/named/{ns}/rag_ingest/trigger
//
// 完整 URL 走環境變數 ARCRUN_TRIGGER_URL(絕不落 repo)。語意:
// - HTTP 2xx =本輪觸發成功 → 對「實際送出」的 added/modified/renamed 事件回寫
// Manifest.MarkIngesteddesign §2 的回寫鉤子,至此才第一次被呼叫)。
// - 非 2xx/網路錯 =不回寫(ingested_hash 不動)→ 下輪掃描自然重發=重試,
// R2 端靠存在檢查 no-op、ingest 端靠 source_hash 冪等(design §5),無腦重試安全。
// - 上傳失敗的 added/modified 事件「不」隨 payload 送出(schema 約定 r2_key=原稿已在
// R2;沒上去就送=消費端 fetch 必 404)——下輪重試補送。renamed/removed 不依賴 R2,照送。
// - 防呆警告輪(mass_delete_guard):removed 事件已被 collector 壓下,但 payload 連同
// warnings 照送(消費端/執行紀錄看得到警告,不執行下架)。
package collector
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// LoadTriggerURLFromEnv 讀 ARCRUN_TRIGGER_URLsync 模式必填)。
func LoadTriggerURLFromEnv() (string, error) {
u := os.Getenv("ARCRUN_TRIGGER_URL")
if u == "" {
return "", fmt.Errorf("sync 缺環境變數:ARCRUN_TRIGGER_URLnamed-webhook 觸發完整 URL,設定只走環境變數,絕不寫進 repo/code")
}
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
return "", fmt.Errorf("ARCRUN_TRIGGER_URL 必須是完整 URLhttp/https),得到:%s", u)
}
return u, nil
}
// TriggerResult 是本輪觸發的結果,隨 sync 輸出 stdout。
type TriggerResult struct {
Status string `json:"status"` // sent | skipped_no_changes | failed | planned--dry-run
HTTPStatus int `json:"http_status,omitempty"`
Error string `json:"error,omitempty"`
MarkedCount int `json:"marked_count"` // 本輪成功回寫 ingested_hash 的檔數
DroppedPaths []string `json:"dropped_paths,omitempty"` // 因上傳失敗被擋下、未隨 payload 送出的事件路徑
}
// BuildSendablePayload 依上傳結果過濾掃描 payload:
// added/modified 只有上傳成功(uploaded/skipped_exists)才隨 payload 送出——
// schema 的 r2_key 語意=「原稿已在 R2」,上傳失敗還送=叫消費端去 404。
// renamed/removed 不依賴 R2 物件,一律保留;warnings 原樣保留(防呆輪照送)。
// 回傳(可送出的 payload 副本, 被擋下的路徑清單)。
func BuildSendablePayload(p *TriggerPayload, uploads []UploadResult) (*TriggerPayload, []string) {
okPaths := map[string]bool{}
for _, u := range uploads {
if u.Status == "uploaded" || u.Status == "skipped_exists" {
okPaths[u.Path] = true
}
}
sendable := *p
sendable.Events = []Event{}
var dropped []string
for _, ev := range p.Events {
if ev.Type == "added" || ev.Type == "modified" {
if !okPaths[ev.Path] {
dropped = append(dropped, ev.Path)
continue
}
}
sendable.Events = append(sendable.Events, ev)
}
return &sendable, dropped
}
// SendTrigger 把 payload POST 到 named-webhook。回傳 HTTP 狀態碼;非 2xx 視為錯誤。
// timeout 放寬到 300snamed-webhook 觸發的 ingest workflow 可能同步跑(demo 實測 20-30s+)。
func SendTrigger(url string, p *TriggerPayload, hc *http.Client) (int, error) {
if hc == nil {
hc = &http.Client{Timeout: 300 * time.Second}
}
body, err := json.Marshal(p)
if err != nil {
return 0, err
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := hc.Do(req)
if err != nil {
return 0, fmt.Errorf("觸發 POST 失敗(不回寫 manifest,下輪自然重試):%w", err)
}
defer resp.Body.Close()
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return resp.StatusCode, fmt.Errorf("觸發回 HTTP %d(不回寫 manifest,下輪自然重試):%s",
resp.StatusCode, strings.TrimSpace(string(snippet)))
}
return resp.StatusCode, nil
}
// MarkIngestedEvents 在觸發成功(2xx)後,對「實際送出」的 added/modified/renamed 事件
// 回寫 ingested_hash。droppedPaths=本輪因上傳失敗被擋下的路徑——同路徑若另有 renamed
// 事件(改名+內容從未成功 ingest 的檔會同輪補發 added),該 renamed 也不得回寫,
// 否則原稿永遠上不了 R2 卻被標成已 ingest。回傳成功回寫的檔數。
func MarkIngestedEvents(m *Manifest, sentEvents []Event, droppedPaths []string, at int64) int {
droppedSet := map[string]bool{}
for _, p := range droppedPaths {
droppedSet[p] = true
}
n := 0
for _, ev := range sentEvents {
switch ev.Type {
case "added", "modified", "renamed":
if droppedSet[ev.Path] {
continue
}
if m.MarkIngested(ev.Path, ev.SourceHash, at) {
n++
}
}
}
return n
}