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

338 lines
9.8 KiB
Go

package server
import (
"context"
"log"
"net/http"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"go.kenn.io/agentsview/internal/config"
"go.kenn.io/agentsview/internal/db"
duckdbsync "go.kenn.io/agentsview/internal/duckdb"
"go.kenn.io/agentsview/internal/postgres"
)
// pushProgressLogInterval bounds how often the daemon-side push handlers log
// progress to debug.log. A package var so tests can shrink it.
var pushProgressLogInterval = 15 * time.Second
// pushProgressStreamInterval bounds how often a push handler forwards progress
// reports onto its SSE stream: the session loop reports per session, which
// would otherwise emit one event per row. A package var so tests can shrink it.
var pushProgressStreamInterval = 200 * time.Millisecond
// newPushProgressStreamSender wraps send so it forwards at most one progress
// report per pushProgressStreamInterval.
func newPushProgressStreamSender[P any](send func(P)) func(P) {
var last time.Time
return func(p P) {
if time.Since(last) < pushProgressStreamInterval {
return
}
last = time.Now()
send(p)
}
}
// newPGPushProgressLogger returns an onProgress callback that logs pg push
// progress at most once per pushProgressLogInterval, phase-aware so the
// vector phase is distinguishable from the session phase.
func newPGPushProgressLogger() func(postgres.PushProgress) {
var last time.Time
return func(p postgres.PushProgress) {
if time.Since(last) < pushProgressLogInterval {
return
}
last = time.Now()
switch p.Phase {
case "preparing":
if p.SessionsTotal == 0 {
log.Printf("pg push: preparing (sync state, metadata, fingerprints)")
return
}
log.Printf("pg push: preparing %d/%d session(s)",
p.SessionsDone, p.SessionsTotal)
case "vectors":
log.Printf("pg push: vectors %d/%d session(s) scanned, %d chunks",
p.VectorSessionsDone, p.VectorSessionsTotal, p.VectorChunksPushed)
default:
log.Printf("pg push: %d/%d session(s), %d messages",
p.SessionsDone, p.SessionsTotal, p.MessagesDone)
}
}
}
// newDuckDBPushProgressLogger is newPGPushProgressLogger's DuckDB analog.
func newDuckDBPushProgressLogger() func(duckdbsync.PushProgress) {
var last time.Time
return func(p duckdbsync.PushProgress) {
if time.Since(last) < pushProgressLogInterval {
return
}
last = time.Now()
log.Printf("duckdb push: %d/%d session(s), %d messages",
p.SessionsDone, p.SessionsTotal, p.MessagesDone)
}
}
func (s *Server) registerPushRoutes() {
group := newRouteGroup(s.api, "/api/v1/push", "Push")
stream(
s, group, http.MethodPost, "/pg",
"Push to PostgreSQL", s.humaPGPush, streamJSONResponse(),
)
stream(
s, group, http.MethodPost, "/duckdb",
"Push to DuckDB", s.humaDuckDBPush, streamJSONResponse(),
)
}
// runPushStream executes run once and writes its outcome to the client: SSE
// progress/done/error events when the request negotiated an event stream
// (the CLI's daemon-delegated push renders these live), a single JSON body
// otherwise. run receives the progress callback to thread into the push;
// it is a throttled SSE sender in stream mode and nil in JSON mode — the
// daemon-side debug.log progress logger is composed by the caller.
func runPushStream[T any](
hctx huma.Context,
run func(onProgress func(T)) (any, error),
) {
if strings.Contains(hctx.Header("Accept"), "text/event-stream") {
if sse, ok := newHumaSSEStream(hctx); ok {
send := newPushProgressStreamSender(func(p T) {
sse.SendJSON("progress", p)
})
result, err := run(send)
if err != nil {
sse.SendJSON("error", map[string]string{"error": err.Error()})
return
}
sse.SendJSON("done", result)
return
}
}
result, err := run(nil)
if err != nil {
writeHumaJSON(hctx, http.StatusInternalServerError,
map[string]string{"error": err.Error()})
return
}
writeHumaJSON(hctx, http.StatusOK, result)
}
// composePushProgress fans one progress report out to each non-nil callback.
func composePushProgress[P any](fns ...func(P)) func(P) {
return func(p P) {
for _, fn := range fns {
if fn != nil {
fn(p)
}
}
}
}
type daemonPushInput struct {
Body daemonPushRequest
}
type daemonPushRequest struct {
Full bool `json:"full"`
Projects []string `json:"projects,omitempty"`
ExcludeProjects []string `json:"exclude_projects,omitempty"`
PG *config.PGConfig `json:"pg,omitempty"`
DuckDB *config.DuckDBConfig `json:"duckdb,omitempty"`
SyncStateTarget string `json:"sync_state_target,omitempty"`
MigrateLegacySyncState bool `json:"migrate_legacy_sync_state,omitempty"`
// NoVectors carries the CLI --no-vectors flag, which has no daemon-side
// flag of its own, into the push handler's vector-source gate.
NoVectors bool `json:"no_vectors,omitempty"`
}
// WithVectorPushSource wires the local vectors.db push source used by the
// daemon's pg push handler. Nil (the default) leaves the vector push phase
// disabled, e.g. when [vector] is not configured.
func WithVectorPushSource(src postgres.VectorPushSource) Option {
return func(s *Server) { s.vectorPushSource = src }
}
func (s *Server) localPushTarget() (*db.DB, error) {
local, ok := s.db.(*db.DB)
if !ok {
return nil, apiError(
http.StatusNotImplemented,
"not available in remote mode",
)
}
return local, nil
}
// pgPushVectorSource returns the vector push source to attach for this push,
// or nil when the phase is gated off: no source is wired ([vector] disabled),
// the target opts out via push_vectors=false, or the caller passed
// --no-vectors. A nil source leaves postgres.Sync's vector phase skipped.
func (s *Server) pgPushVectorSource(
pgCfg config.PGConfig, noVectors bool,
) postgres.VectorPushSource {
if s.vectorPushSource == nil || !pgCfg.PushVectorsEnabled() || noVectors {
return nil
}
return s.vectorPushSource
}
func (s *Server) pgPushConfig(req daemonPushRequest) (config.PGConfig, error) {
if req.PG != nil {
return *req.PG, nil
}
return s.cfg.ResolvePG()
}
func (s *Server) duckDBPushConfig(
req daemonPushRequest,
) (config.DuckDBConfig, error) {
if req.DuckDB != nil {
return *req.DuckDB, nil
}
return s.cfg.ResolveDuckDB()
}
func duckDBPushSyncOptions(
req daemonPushRequest,
duckCfg config.DuckDBConfig,
) duckdbsync.SyncOptions {
syncStateTarget := req.SyncStateTarget
if syncStateTarget == "" {
syncStateTarget = duckdbsync.SyncStateTargetForConfig(duckCfg)
}
return duckdbsync.SyncOptions{
Projects: req.Projects,
ExcludeProjects: req.ExcludeProjects,
SyncStateTarget: syncStateTarget,
}
}
func (s *Server) humaPGPush(
ctx context.Context,
in *daemonPushInput,
) (*huma.StreamResponse, error) {
if err := postgres.ValidateProjectFilters(
in.Body.Projects,
in.Body.ExcludeProjects,
); err != nil {
return nil, apiError(http.StatusBadRequest, err.Error())
}
local, err := s.localPushTarget()
if err != nil {
return nil, err
}
pgCfg, err := s.pgPushConfig(in.Body)
if err != nil {
return nil, apiError(http.StatusBadRequest, err.Error())
}
if pgCfg.URL == "" {
return nil, apiError(http.StatusBadRequest, "pg push: url not configured")
}
engine := s.syncEngineForLocal(local)
vectorSource := s.pgPushVectorSource(pgCfg, in.Body.NoVectors)
body := in.Body
return &huma.StreamResponse{Body: func(hctx huma.Context) {
runPushStream(hctx, func(
streamProgress func(postgres.PushProgress),
) (any, error) {
onProgress := composePushProgress(
newPGPushProgressLogger(), streamProgress,
)
var result postgres.PushResult
_, err := engine.SyncThenRun(ctx, body.Full, nil,
func(forceFull bool) error {
ps, err := postgres.New(
pgCfg.URL, pgCfg.Schema, local,
pgCfg.MachineName, pgCfg.AllowInsecure,
postgres.SyncOptions{
Projects: body.Projects,
ExcludeProjects: body.ExcludeProjects,
SyncStateTarget: body.SyncStateTarget,
MigrateLegacySyncState: body.MigrateLegacySyncState,
VectorSource: vectorSource,
},
)
if err != nil {
return err
}
defer ps.Close()
if err := ps.EnsureSchema(ctx); err != nil {
return err
}
result, err = ps.Push(ctx, forceFull, onProgress)
return err
})
return result, err
})
}}, nil
}
func (s *Server) humaDuckDBPush(
ctx context.Context,
in *daemonPushInput,
) (*huma.StreamResponse, error) {
if err := postgres.ValidateProjectFilters(
in.Body.Projects,
in.Body.ExcludeProjects,
); err != nil {
return nil, apiError(http.StatusBadRequest, err.Error())
}
local, err := s.localPushTarget()
if err != nil {
return nil, err
}
duckCfg, err := s.duckDBPushConfig(in.Body)
if err != nil {
return nil, apiError(http.StatusBadRequest, err.Error())
}
if err := duckdbsync.ValidatePushTarget(duckCfg); err != nil {
return nil, apiError(http.StatusBadRequest, err.Error())
}
engine := s.syncEngineForLocal(local)
opts := duckDBPushSyncOptions(in.Body, duckCfg)
body := in.Body
return &huma.StreamResponse{Body: func(hctx huma.Context) {
runPushStream(hctx, func(
streamProgress func(duckdbsync.PushProgress),
) (any, error) {
onProgress := composePushProgress(
newDuckDBPushProgressLogger(), streamProgress,
)
var result duckdbsync.PushResult
_, err := engine.SyncThenRun(ctx, body.Full, nil,
func(forceFull bool) error {
var syncer *duckdbsync.Sync
var err error
if duckCfg.URL != "" {
syncer, err = duckdbsync.NewFromConfig(
duckCfg, local, opts,
)
} else {
syncer, err = duckdbsync.New(
duckCfg.Path, local, duckCfg.MachineName,
opts,
)
}
if err != nil {
return err
}
defer syncer.Close()
if err := syncer.EnsureSchema(ctx); err != nil {
return err
}
result, err = syncer.Push(ctx, forceFull, onProgress)
return err
})
return result, err
})
}}, nil
}