jim800121chen 3f0175f1a9 feat(local-agent): Phase 0.5 visionA Agent — Wails 桌面 + tunnel client + 配對 UI
從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑:
tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。
Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local),
雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。

Backend / Wails Go(AB1-AB13):
- internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped)
  + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event
- internal/auth:encrypted file token store(AES-GCM + scrypt + machineID
  fallback salt + 13 tests)
- internal/config:YAML validation + atomic write + 11 tests
- internal/log:ring buffer + ExportLog 升級 zip
- visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests
- 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage)
- end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護
  → tunnel drop failover)

Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎):
- AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab)
- ConnectionStatusBadge 5 種狀態
- TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁
- 設定頁 4 區塊(含重新配對 AlertDialog)
- agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests

Phase 0.7 review-driven fix(Round 2):
- A1 Session fixation 防護(RotateSessionID)
- A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log
- A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態)
- A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test
- F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL
  / F4 Settings draft 持久 + 未儲存 badge

驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 /
agent frontend pnpm test 119 tests 全綠

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:22:01 +08:00

381 lines
14 KiB
Go
Raw Permalink 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.

package main
import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"visiona-agent/server/internal/api"
"visiona-agent/server/internal/api/handlers"
"visiona-agent/server/internal/api/ws"
"visiona-agent/server/internal/camera"
"visiona-agent/server/internal/config"
"visiona-agent/server/internal/deps"
"visiona-agent/server/internal/device"
"visiona-agent/server/internal/flash"
"visiona-agent/server/internal/inference"
"visiona-agent/server/internal/model"
pkglogger "visiona-agent/server/pkg/logger"
"visiona-agent/server/web"
)
var (
Version = "dev"
BuildTime = "unknown"
)
// baseDir returns the base directory for resolving data/ and scripts/ paths.
// In dev mode (go run), uses the working directory.
// In production (compiled binary), uses the binary's directory so the server
// works correctly regardless of the working directory.
func baseDir(devMode bool) string {
if devMode {
return "."
}
exe, err := os.Executable()
if err != nil {
return "."
}
return filepath.Dir(exe)
}
// findFirstExisting tries each candidate directory and returns the first one
// that contains `sentinel` as a regular file. Returned path is absolute.
//
// If no candidate hits, returns ("", tried) where `tried` is the absolute
// form of every candidate that was checked — callers can log this for
// debugging. Callers are expected to supply their own fallback value.
func findFirstExisting(candidates []string, sentinel string) (string, []string) {
tried := make([]string, 0, len(candidates))
for _, c := range candidates {
abs, err := filepath.Abs(c)
if err != nil {
tried = append(tried, c)
continue
}
tried = append(tried, abs)
if info, err := os.Stat(filepath.Join(abs, sentinel)); err == nil && !info.IsDir() {
return abs, tried
}
}
return "", tried
}
// resolveBridgeScript finds the directory holding kneron_bridge.py across
// different packaging layouts, then returns the absolute path to the script.
//
// Possible locations (tried in order):
// 1. <env VISIONA_BUNDLE_LIB_DIR>/scripts — Linux AppImage (AppRun exports this)
// 2. <base>/scripts — dev mode or flat layout
// 3. <base>/../scripts — Windows/Linux installer: {app}/bin/<exe>, {app}/scripts/
// 4. <base>/../Resources/scripts — macOS app bundle: Contents/Resources/bin/<exe>, Contents/Resources/scripts/
// 5. <base>/../lib/visiona-agent/scripts — Linux AppImage FHS: usr/bin/<exe>, usr/lib/visiona-agent/scripts/
// 6. ./scripts — cwd fallback
func resolveBridgeScript(base string) string {
candidates := []string{}
if libDir := os.Getenv("VISIONA_BUNDLE_LIB_DIR"); libDir != "" {
candidates = append(candidates, filepath.Join(libDir, "scripts"))
}
candidates = append(candidates,
filepath.Join(base, "scripts"),
filepath.Join(base, "..", "scripts"),
filepath.Join(base, "..", "Resources", "scripts"),
filepath.Join(base, "..", "lib", "visiona-agent", "scripts"),
filepath.Join(".", "scripts"),
)
if dir, tried := findFirstExisting(candidates, "kneron_bridge.py"); dir != "" {
return filepath.Join(dir, "kneron_bridge.py")
} else {
log.Printf("warn: kneron_bridge.py not found. Tried: %v", tried)
}
// Fallback — return the default so downstream logs a clear error
abs, err := filepath.Abs(filepath.Join(base, "scripts", "kneron_bridge.py"))
if err != nil {
return filepath.Join(base, "scripts", "kneron_bridge.py")
}
return abs
}
// resolveBuiltInDataDir finds the bundle-internal data/ directory that ships
// with the binary. This directory is *read-only* at runtime and holds the
// built-in model catalog (models.json + nef/kl520/ + nef/kl720/).
//
// This is different from the user data directory (lock, ipc-port, logs,
// custom-models, preferences.json, sentinel file) which is writable and lives
// under the OS-specific app-data location. See main() for the split.
//
// Possible locations (tried in order):
// 1. <env VISIONA_BUNDLE_LIB_DIR>/data — Linux AppImage (AppRun exports this)
// 2. <base>/data — dev mode or flat layout (cwd == repo/server/)
// 3. <base>/../data — Windows/Linux installer: {app}/bin/<exe>, {app}/data/
// 4. <base>/../Resources/data — macOS app bundle: Contents/Resources/bin/<exe>, Contents/Resources/data/
// 5. <base>/../lib/visiona-agent/data — Linux AppImage FHS: usr/bin/<exe>, usr/lib/visiona-agent/data/
//
// A candidate counts as a hit only if models.json exists inside it as a
// regular file — this avoids false positives from empty `data/` directories
// that Wails sometimes leaves behind in build artifacts.
func resolveBuiltInDataDir(base string) string {
candidates := []string{}
if libDir := os.Getenv("VISIONA_BUNDLE_LIB_DIR"); libDir != "" {
candidates = append(candidates, filepath.Join(libDir, "data"))
}
candidates = append(candidates,
filepath.Join(base, "data"),
filepath.Join(base, "..", "data"),
filepath.Join(base, "..", "Resources", "data"),
filepath.Join(base, "..", "lib", "visiona-agent", "data"),
)
if dir, tried := findFirstExisting(candidates, "models.json"); dir != "" {
return dir
} else {
log.Printf("warn: built-in data dir (models.json) not found. Tried: %v", tried)
}
// Fallback — return the default so downstream logs a clear error
abs, err := filepath.Abs(filepath.Join(base, "data"))
if err != nil {
return filepath.Join(base, "data")
}
return abs
}
func main() {
cfg := config.Load()
logger := pkglogger.New(cfg.LogLevel)
logger.Info("Starting visionA Agent Server %s (built: %s)", Version, BuildTime)
logger.Info("Dev mode: %v, Python mode: %s", cfg.DevMode, cfg.PythonMode)
// 把 VISIONA_BUNDLE_BIN_DIR 加到 PATH讓 exec.Command("ffmpeg") / exec.Command("ffprobe")
// 能透過 LookPath 找到 bundle 內的 binaryGo 1.19+ Windows 不再搜 cwd
if bundleBin := os.Getenv("VISIONA_BUNDLE_BIN_DIR"); bundleBin != "" {
sep := string(os.PathListSeparator)
os.Setenv("PATH", bundleBin+sep+os.Getenv("PATH"))
logger.Info("Added VISIONA_BUNDLE_BIN_DIR to PATH: %s", bundleBin)
}
// Check external dependencies
deps.PrintStartupReport(logger)
// Resolve base directory.
base := baseDir(cfg.DevMode)
// Resolve built-in data directory (read-only, ships with the binary).
// Holds models.json + nef/kl520/ + nef/kl720/. Auto-detected across
// dev / installer / macOS-bundle layouts; see resolveBuiltInDataDir().
builtInDataDir := resolveBuiltInDataDir(base)
logger.Info("Built-in data dir: %s", builtInDataDir)
// Resolve user data directory (writable). Holds lock, ipc-port, logs,
// custom-models, preferences.json, sentinel. Wails passes this via
// --data-dir pointing at the OS app-data location.
//
// Standalone fallback: when no --data-dir is given we reuse builtInDataDir
// so `go run ./server` and direct binary launches keep working for local
// development. In *production*, Wails always passes --data-dir, so this
// branch never lands on a read-only bundle path. If someone does run the
// packaged binary with no --data-dir, the writable operations (sentinel,
// logs, custom-models) will fail against the read-only bundle dir and the
// affected code paths log warnings — they don't crash the server.
dataDir := cfg.DataDir
if dataDir == "" {
dataDir = builtInDataDir
}
// Initialize model repository (built-in models from JSON).
// Always read from the built-in data dir — not the user data dir —
// so Wails passing --data-dir doesn't accidentally blank out the catalog.
modelRepo := model.NewRepository(filepath.Join(builtInDataDir, "models.json"))
logger.Info("Loaded %d built-in models", modelRepo.Count())
// Initialize model store (custom uploaded models) — writable, user dataDir.
customModelDir := cfg.ModelDir
if customModelDir == "" {
customModelDir = filepath.Join(dataDir, "custom-models")
}
modelStore := model.NewModelStore(customModelDir)
customModels, err := modelStore.LoadCustomModels()
if err != nil {
logger.Warn("Failed to load custom models: %v", err)
}
for _, m := range customModels {
modelRepo.Add(m)
}
if len(customModels) > 0 {
logger.Info("Loaded %d custom models", len(customModels))
}
// Initialize WebSocket hub (before device manager so log broadcaster is ready)
wsHub := ws.NewHub()
// M8-4b注入 dataDir 給 Hub第一個 WebSocket client 連上時會在
// <dataDir>/.first-ws-connected 寫 sentinel file讓 Wails 端的
// StartupPipeline 知道階段 6Wait for Web UI WebSocket已完成。
// 詳見 .autoflow/04-architecture/v2/startup-pipeline.md §3 階段 6。
wsHub.SetStartupSentinel(dataDir)
go wsHub.Run()
// Initialize log broadcaster for real-time log streaming
logBroadcaster := pkglogger.NewBroadcaster(500, func(entry pkglogger.LogEntry) {
wsHub.BroadcastToRoom("server-logs", entry)
})
logger.SetBroadcaster(logBroadcaster)
// Initialize device manager
registry := device.NewRegistry()
bridgeScript := resolveBridgeScript(base)
logger.Info("Kneron bridge script: %s", bridgeScript)
deviceMgr := device.NewManager(registry, bridgeScript)
deviceMgr.SetLogBroadcaster(logBroadcaster)
deviceMgr.Start()
// Initialize camera manager
cameraMgr := camera.NewManager()
// Initialize services.
// flash.Service resolves relative `.nef` paths from models.json against
// builtInDataDir (not dataDir), since the .nef files ship alongside
// models.json in the read-only bundle, not in the writable user dataDir.
flashSvc := flash.NewService(deviceMgr, modelRepo, builtInDataDir)
inferenceSvc := inference.NewService(deviceMgr)
// Determine static file system for embedded frontend
var staticFS http.FileSystem
if !cfg.DevMode {
staticFS = web.StaticFS()
logger.Info("Serving embedded frontend static files")
} else {
logger.Info("Dev mode: frontend static serving disabled (use Wails dev server)")
}
// Build HTTP server (needed for graceful shutdown and restart)
var httpServer *http.Server
restartRequested := make(chan struct{}, 1)
shutdownFn := func() {
// MAJ-3 修復timeout 必須 ≤ Wails shutdownGracePeriod (7s),留 1s buffer。
// TDD §8.1Wails 端 7s grace + 1s modalserver 端 6s 內必須完成清理,
// 否則 Wails 在第 7s SIGKILL 時 server 還在 sync 檔案會被打斷。
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
inferenceSvc.StopAll()
cameraMgr.Close()
if httpServer != nil {
_ = httpServer.Shutdown(ctx)
}
}
restartFn := func() {
// Signal the main goroutine to perform exec after server shutdown
select {
case restartRequested <- struct{}{}:
default:
}
shutdownFn()
}
// Resolve python bin (used by InstallDriver handler on Windows).
// Priority: VISIONA_PYTHON env var (set by Wails shell) → cfg.PythonBin (--python flag)
pythonBinForSystem := os.Getenv("VISIONA_PYTHON")
if pythonBinForSystem == "" {
pythonBinForSystem = cfg.PythonBin
}
// Create system handler with injected version and restart function
systemHandler := handlers.NewSystemHandler(Version, BuildTime, pythonBinForSystem, restartFn, wsHub)
// Create router
r := api.NewRouter(modelRepo, modelStore, deviceMgr, cameraMgr, flashSvc, inferenceSvc, wsHub, staticFS, logBroadcaster, systemHandler)
// Configure HTTP server (bind to localhost only)
addr := cfg.Addr()
httpServer = &http.Server{
Addr: addr,
Handler: r,
}
// Handle OS signals for graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-quit
logger.Info("Received signal %v, shutting down gracefully...", sig)
shutdownFn()
os.Exit(0)
}()
// Kill existing process on the port if occupied
killExistingProcess(addr, logger)
// Start server
logger.Info("Server listening on %s", addr)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("Failed to start server: %v", err)
}
// If restart was requested, exec the same binary to replace this process
select {
case <-restartRequested:
logger.Info("Performing self-restart via exec...")
exe, err := os.Executable()
if err != nil {
log.Fatalf("Failed to get executable path: %v", err)
}
exe, err = filepath.EvalSymlinks(exe)
if err != nil {
log.Fatalf("Failed to resolve executable symlinks: %v", err)
}
_ = syscall.Exec(exe, os.Args, os.Environ())
log.Fatalf("syscall.Exec failed")
default:
// Normal shutdown, just exit
}
}
// killExistingProcess checks if the port is already in use and kills the
// occupying process so the server can start cleanly.
func killExistingProcess(addr string, logger *pkglogger.Logger) {
// Extract port from addr (e.g. "127.0.0.1:3721" → "3721")
_, port, err := net.SplitHostPort(addr)
if err != nil {
return
}
// Quick check: try to listen — if it works, port is free
ln, err := net.Listen("tcp", addr)
if err == nil {
ln.Close()
return
}
// Port is occupied, find and kill the process
logger.Info("Port %s is in use, killing existing process...", port)
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", fmt.Sprintf("for /f \"tokens=5\" %%a in ('netstat -ano ^| findstr :%s') do taskkill /F /PID %%a", port))
} else {
cmd = exec.Command("sh", "-c", fmt.Sprintf("lsof -ti:%s | xargs kill -9 2>/dev/null", port))
}
output, err := cmd.CombinedOutput()
if err != nil {
logger.Warn("Failed to kill process on port %s: %v (%s)", port, err, strings.TrimSpace(string(output)))
return
}
// Wait briefly for port to be released
time.Sleep(500 * time.Millisecond)
logger.Info("Previous process killed, port %s is now free", port)
}