Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

515 lines
14 KiB
Go

// ABOUTME: detectTransport picks between the HTTP and direct-DB
// ABOUTME: SessionService backends based on whether a running
// ABOUTME: agentsview daemon is discoverable via its kit runtime record.
package main
import (
"context"
"errors"
"fmt"
"log"
"net"
"os"
"strconv"
"strings"
"testing"
"time"
"go.kenn.io/agentsview/internal/config"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/postgres"
"go.kenn.io/agentsview/internal/service"
"go.kenn.io/agentsview/internal/update"
)
type transportMode int
const (
transportDirect transportMode = iota
transportHTTP
)
type transportIntent int
const (
transportIntentRead transportIntent = iota
transportIntentArchiveWrite
)
var errLocalDaemonUnreachable = errors.New(
"local daemon owns the SQLite archive but is not responding",
)
var startBackgroundServeForTransport = autoStartBackgroundServe
var waitForDaemonStartupForTransport = WaitForDaemonStartupContext
// autoStartBackgroundServe guards transport auto-start against test
// binaries: os.Executable inside `go test` is the test executable, so a
// CLI test reaching this path would detach a real `agentsview.test serve`
// daemon that outlives the test run and can squat on the real daemon's
// port. Tests must stub startBackgroundServeForTransport or set
// AGENTSVIEW_NO_DAEMON=1; tests of the auto-start machinery itself call
// ensureBackgroundServe directly.
func autoStartBackgroundServe(
ctx context.Context, cfg *config.Config, waitTimeout time.Duration,
) (*DaemonRuntime, error) {
if testing.Testing() {
return nil, errors.New(
"refusing to auto-start a background daemon from a test binary; " +
"stub startBackgroundServeForTransport or set AGENTSVIEW_NO_DAEMON=1",
)
}
return ensureBackgroundServe(ctx, cfg, waitTimeout)
}
// transport captures how to reach the session-data layer from a
// CLI subcommand. Either the HTTP daemon (URL set) or the local DB.
type transport struct {
Mode transportMode
URL string
ReadOnly bool // daemon runtime ReadOnly flag (true for pg serve)
DirectReadOnly bool // writable daemon owns DB but is not reachable
DirectIncompatible bool // live daemon owns DB but cannot serve this client
DirectReason string
Runtime *DaemonRuntime
}
type customPricingStore interface {
SetCustomPricing(map[string]config.CustomModelRate)
}
var openPGReadStore = func(
cfg config.Config,
pgCfg config.PGConfig,
) (db.Store, func(), error) {
applyClassifierConfig(cfg)
store, err := postgres.NewStore(
pgCfg.URL, pgCfg.Schema, pgCfg.AllowInsecure,
)
if err != nil {
return nil, nil, err
}
return store, func() { _ = store.Close() }, nil
}
// detectTransport picks the transport mode:
// 1. If a kit runtime record points to a live daemon, use HTTP.
// 2. If a daemon start lock exists, wait up to waitTimeout for the
// daemon to become ready, then try again.
// 3. If a writable local daemon owns the SQLite archive but is not
// reachable by ping, use direct read-only access.
// 4. Otherwise use direct access.
func detectTransport(
dataDir string, authToken string, waitTimeout time.Duration,
) (transport, error) {
return detectTransportContext(
context.Background(), dataDir, authToken, waitTimeout,
)
}
func detectTransportContext(
ctx context.Context,
dataDir string,
authToken string,
waitTimeout time.Duration,
) (transport, error) {
if err := ctx.Err(); err != nil {
return transport{}, err
}
if isExternalDaemonStarting(dataDir) {
fmt.Fprintln(os.Stderr,
"server is starting up, waiting...")
if waitTimeout <= 0 {
waitTimeout = startupWaitTimeout
}
_, _, err := waitForExternalServeStartup(
ctx, dataDir, authToken, waitTimeout,
)
if err != nil && errors.Is(err, errServeStartupInProgress) {
return transport{}, err
}
if err := ctx.Err(); err != nil {
return transport{}, err
}
}
if sf := FindDaemonRuntime(dataDir, authToken); sf != nil {
return transportFromRuntime(sf), nil
}
if IsDaemonStarting(dataDir) {
fmt.Fprintln(os.Stderr,
"server is starting up, waiting...")
if waitTimeout <= 0 {
waitTimeout = startupWaitTimeout
}
waitForDaemonStartupForTransport(
ctx, dataDir, waitTimeout, authToken,
)
if err := ctx.Err(); err != nil {
return transport{}, err
}
if sf := FindDaemonRuntime(dataDir, authToken); sf != nil {
return transportFromRuntime(sf), nil
}
}
if IsLocalDaemonActive(dataDir, authToken) {
reason := errLocalDaemonUnreachable.Error()
incompatible := false
if _, err := FindIncompatibleDaemonRuntime(dataDir, authToken); err != nil {
reason = err.Error()
incompatible = true
}
return transport{
Mode: transportDirect,
DirectReadOnly: true,
DirectIncompatible: incompatible,
DirectReason: reason,
}, nil
}
return transport{Mode: transportDirect}, nil
}
func ensureTransport(
cfg *config.Config,
intent transportIntent,
waitTimeout time.Duration,
) (transport, error) {
return ensureTransportContext(
context.Background(), cfg, intent, waitTimeout,
)
}
func ensureTransportContext(
ctx context.Context,
cfg *config.Config,
intent transportIntent,
waitTimeout time.Duration,
) (transport, error) {
if cfg == nil {
return transport{}, errors.New("nil config")
}
if err := ctx.Err(); err != nil {
return transport{}, err
}
if (intent == transportIntentRead || intent == transportIntentArchiveWrite) &&
waitTimeout <= 0 {
waitTimeout = backgroundAutoStartReadyTimeout
}
if intent == transportIntentArchiveWrite {
waited, err := waitForBackgroundLaunchBeforeArchiveWrite(
ctx, cfg.DataDir, waitTimeout,
)
if err != nil {
return transport{}, err
}
if waited && cfg.AuthToken == "" {
adoptBackgroundLaunchConfig(cfg)
}
}
tr, err := detectTransportContext(
ctx, cfg.DataDir, cfg.AuthToken, waitTimeout,
)
if err != nil {
return transport{}, err
}
if intent == transportIntentRead && cfg.AuthToken == "" &&
tr.Mode == transportDirect && tr.DirectReadOnly {
adoptBackgroundLaunchConfig(cfg)
if cfg.AuthToken != "" {
tr, err = detectTransportContext(
ctx, cfg.DataDir, cfg.AuthToken, waitTimeout,
)
if err != nil {
return transport{}, err
}
}
}
if tr.Mode == transportHTTP {
if (intent == transportIntentRead ||
intent == transportIntentArchiveWrite) &&
shouldUpgradeDaemonRuntime(tr.Runtime, version) {
if daemonAutostartDisabled() {
if intent == transportIntentRead {
return transport{}, appendDaemonRestartUpgradeHint(
errors.New("daemon restart required: running daemon is older than this client"),
)
}
return tr, nil
}
if err := guardDaemonAutoStartConfig(*cfg); err != nil {
return transport{}, err
}
cfg.NoSync = cfg.NoSync || tr.Runtime.NoSync
rt, err := startBackgroundServeForTransport(
ctx, cfg, waitTimeout,
)
if err != nil {
return transport{}, err
}
return transportFromRuntime(rt), nil
}
return tr, nil
}
if (intent == transportIntentRead || intent == transportIntentArchiveWrite) &&
!daemonAutostartDisabled() {
if rt, err := FindIncompatibleDaemonRuntime(
cfg.DataDir, cfg.AuthToken,
); err != nil && rt != nil &&
shouldUpgradeIncompatibleDaemonRuntime(rt, version) {
if err := guardDaemonAutoStartConfig(*cfg); err != nil {
return transport{}, err
}
cfg.NoSync = cfg.NoSync || rt.NoSync
rt, err := startBackgroundServeForTransport(
ctx, cfg, waitTimeout,
)
if err != nil {
return transport{}, err
}
return transportFromRuntime(rt), nil
}
}
if intent == transportIntentRead {
if daemonAutostartDisabled() {
return transport{}, errors.New(
"daemon autostart is disabled; direct SQLite reads are " +
"not supported for this command. Start a daemon with " +
"`agentsview daemon start` or unset " +
"AGENTSVIEW_NO_DAEMON",
)
}
if tr.DirectReadOnly {
if tr.DirectReason != "" {
if tr.DirectReason == errLocalDaemonUnreachable.Error() {
return transport{}, errLocalDaemonUnreachable
}
return transport{}, appendDaemonRestartUpgradeHint(
errors.New(tr.DirectReason),
)
}
return transport{}, errLocalDaemonUnreachable
}
if err := guardDaemonAutoStartConfig(*cfg); err != nil {
return transport{}, err
}
rt, err := startBackgroundServeForTransport(ctx, cfg, waitTimeout)
if err != nil {
return transport{}, err
}
return transportFromRuntime(rt), nil
}
if daemonAutostartDisabled() {
return tr, nil
}
if tr.DirectReadOnly {
if tr.DirectReason != "" {
if tr.DirectReason == errLocalDaemonUnreachable.Error() {
return transport{}, errLocalDaemonUnreachable
}
return transport{}, errors.New(tr.DirectReason)
}
return transport{}, errLocalDaemonUnreachable
}
if err := guardDaemonAutoStartConfig(*cfg); err != nil {
return transport{}, err
}
rt, err := startBackgroundServeForTransport(ctx, cfg, waitTimeout)
if err != nil {
return transport{}, err
}
return transportFromRuntime(rt), nil
}
func waitForBackgroundLaunchBeforeArchiveWrite(
ctx context.Context,
dataDir string,
waitTimeout time.Duration,
) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
info, err := os.Stat(dataDir)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
if !info.IsDir() {
return false, fmt.Errorf("data dir %q is not a directory", dataDir)
}
if !isBackgroundLaunchActive(dataDir) {
return false, nil
}
if waitTimeout <= 0 {
waitTimeout = backgroundAutoStartReadyTimeout
}
deadline := time.Now().Add(waitTimeout)
for isBackgroundLaunchActive(dataDir) {
if err := ctx.Err(); err != nil {
return true, err
}
remaining := time.Until(deadline)
if remaining <= 0 {
return true, errServeStartupInProgress
}
timer := time.NewTimer(min(remaining, startProbeTick()))
select {
case <-ctx.Done():
timer.Stop()
return true, ctx.Err()
case <-timer.C:
}
}
return true, ctx.Err()
}
func shouldUpgradeDaemonRuntime(rt *DaemonRuntime, currentVersion string) bool {
if rt == nil || rt.ReadOnly {
return false
}
if update.IsDevBuildVersion(currentVersion) {
return false
}
if rt.Record.Version == "" {
return true
}
return update.IsNewer(currentVersion, rt.Record.Version)
}
func shouldUpgradeIncompatibleDaemonRuntime(
rt *DaemonRuntime, currentVersion string,
) bool {
if rt == nil {
return false
}
if !shouldUpgradeDaemonRuntime(rt, currentVersion) {
return false
}
if rt.API > daemonAPIVersion || rt.Data > db.CurrentDataVersion() {
return false
}
return true
}
func guardDaemonAutoStartConfig(cfg config.Config) error {
host := strings.TrimSpace(cfg.Host)
if host == "" || cfg.RequireAuth || isLoopbackHost(host) {
return nil
}
return fmt.Errorf(
"refusing to auto-start an unauthenticated daemon on non-loopback "+
"host %q; enable require_auth, bind serve to 127.0.0.1, "+
"start 'agentsview serve --background' explicitly, or set "+
"AGENTSVIEW_NO_DAEMON=1 for direct local writes",
host,
)
}
func daemonAutostartDisabled() bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv("AGENTSVIEW_NO_DAEMON")))
switch v {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// transportFromRuntime builds the HTTP transport a CLI client uses to reach a
// resolved daemon runtime.
func transportFromRuntime(rt *DaemonRuntime) transport {
return transport{
Mode: transportHTTP,
URL: urlFromDaemonRuntime(rt),
ReadOnly: rt.ReadOnly,
Runtime: rt,
}
}
// urlFromDaemonRuntime returns the HTTP URL a CLI client should use
// to reach the daemon described by rt. Bind-all addresses are
// mapped to loopback. IPv6 hosts are bracketed via
// net.JoinHostPort so the URL is well-formed.
func urlFromDaemonRuntime(rt *DaemonRuntime) string {
host := rt.Host
switch host {
case "", "0.0.0.0":
host = "127.0.0.1"
case "::":
host = "::1"
}
return "http://" + net.JoinHostPort(host, strconv.Itoa(rt.Port))
}
// newService builds the SessionService matching the detected
// transport. The returned cleanup function must be called when
// the caller is done with the service.
func newService(
cfg config.Config, tr transport,
) (service.SessionService, func(), error) {
switch tr.Mode {
case transportHTTP:
return service.NewHTTPBackend(tr.URL, cfg.AuthToken, tr.ReadOnly),
func() {}, nil
default:
if err := directIncompatibleDaemonError(tr); err != nil {
return nil, nil, err
}
d, err := openReadOnlyDB(cfg)
if err != nil {
return nil, nil, fmt.Errorf(
"opening db: %w", err,
)
}
closeVectorSearcher := installDirectVectorSearcher(cfg, d)
cleanup := func() {
if closeVectorSearcher != nil {
if cerr := closeVectorSearcher(); cerr != nil {
log.Printf("close vectors.db: %v", cerr)
}
}
d.Close()
}
// engine is nil — CLI reads don't need it, and Sync
// is handled via the HTTP daemon when one is running.
return service.NewDirectBackend(d, nil), cleanup, nil
}
}
func directIncompatibleDaemonError(tr transport) error {
if !tr.DirectIncompatible {
return nil
}
reason := strings.TrimSpace(tr.DirectReason)
if reason == "" {
reason = "local daemon is incompatible with this agentsview client"
}
return appendDaemonRestartUpgradeHint(errors.New(reason))
}
// newPGReadService builds a read-only SessionService over the
// configured PostgreSQL sync store. It shares the same store
// construction path as pg serve, but leaves schema repair/migration
// to pg push/serve because CLI read commands never mutate PG. Like
// pg serve, it runs the PG vector gate so `session search --pg
// --semantic|--hybrid` and `mcp --pg` get the same semantic search
// the SQLite direct path wires via installDirectVectorSearcher.
func newPGReadService(
cfg config.Config, pgCfg config.PGConfig,
) (service.SessionService, func(), error) {
store, cleanup, err := openPGReadStore(cfg, pgCfg)
if err != nil {
if cleanup != nil {
cleanup()
}
return nil, nil, fmt.Errorf("opening pg store: %w", err)
}
if len(cfg.CustomModelPricing) > 0 {
if priced, ok := store.(customPricingStore); ok {
priced.SetCustomPricing(cfg.CustomModelPricing)
}
}
wirePGReadVectorSearchFn(cfg, store)
return service.NewReadOnlyBackend(store), cleanup, nil
}