Files
wehub-resource-sync f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

782 lines
19 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"io"
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/spf13/cobra"
"go.kenn.io/agentsview/internal/config"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/postgres"
"go.kenn.io/agentsview/internal/server"
)
type PGPushConfig struct {
Full bool
AllTargets bool
ProjectsFlag string
ExcludeProjects string
AllProjects bool
Watch bool
Debounce time.Duration
Interval time.Duration
NoVectors bool
}
type PGStatusConfig struct {
AllTargets bool
ProjectsFlag string
ExcludeProjects string
AllProjects bool
}
type pgTargetSelection struct {
Name string
PG config.PGConfig
IsDefault bool
SyncStateTarget string
MigrateLegacySyncState bool
}
func (s pgTargetSelection) label() string {
if s.Name == "" {
return "default"
}
if s.IsDefault {
return s.Name + " (default)"
}
return s.Name
}
func (s pgTargetSelection) syncOptions(
projects, excludeProjects []string,
vectorSource postgres.VectorPushSource,
) postgres.SyncOptions {
return postgres.SyncOptions{
Projects: projects,
ExcludeProjects: excludeProjects,
SyncStateTarget: s.SyncStateTarget,
MigrateLegacySyncState: s.MigrateLegacySyncState,
VectorSource: vectorSource,
}
}
// pgVectorPushSource returns the vectors.db push source to attach for this
// target, or nil when the vector push phase is gated off: the target opts out
// via push_vectors=false, the caller passed --no-vectors, or [vector] is
// disabled (newVectorPushSource itself returns nil then). A nil source leaves
// postgres.Sync's vector phase skipped.
func pgVectorPushSource(
appCfg config.Config, target pgTargetSelection, cfg PGPushConfig,
) postgres.VectorPushSource {
if !target.PG.PushVectorsEnabled() || cfg.NoVectors {
return nil
}
return newVectorPushSource(appCfg)
}
func runPGPush(
cfg PGPushConfig, targetName string,
) error {
appCfg, err := config.LoadMinimal()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
if err := os.MkdirAll(appCfg.DataDir, 0o755); err != nil {
return fmt.Errorf("creating data dir: %w", err)
}
setupLogFile(appCfg.DataDir)
targets, err := resolvePGTargetSelections(
appCfg, targetName, cfg.AllTargets,
)
if err != nil {
return err
}
applyClassifierConfig(appCfg)
ctx, stop := signal.NotifyContext(
context.Background(), os.Interrupt,
)
defer stop()
backend, cleanup, err := resolveArchiveWriteBackend(ctx, appCfg)
if err != nil {
return fmt.Errorf("opening writer: %w", err)
}
defer cleanup()
var failures []string
for i, target := range targets {
if len(targets) > 1 || target.Name != "" {
if i > 0 {
fmt.Println()
}
fmt.Printf("Target: %s\n", target.label())
}
if err := runPGPushTarget(
ctx, backend, appCfg, cfg, target,
); err != nil {
if len(targets) == 1 {
return err
}
failures = append(
failures,
fmt.Sprintf("%s: %v", target.label(), err),
)
fmt.Fprintf(
os.Stderr,
"warning: pg push target %s failed: %v\n",
target.label(), err,
)
}
}
if len(failures) > 0 {
return fmt.Errorf(
"%d pg target(s) failed: %s",
len(failures),
strings.Join(failures, "; "),
)
}
return nil
}
func runPGPushTarget(
ctx context.Context,
backend archiveWriteBackend,
appCfg config.Config,
cfg PGPushConfig,
target pgTargetSelection,
) error {
target, err := resolvePGTargetConfig(appCfg, target)
if err != nil {
return err
}
if target.PG.URL == "" {
return fmt.Errorf("url not configured")
}
projects, excludeProjects, err := resolvePushProjects(
target.PG, cfg,
)
if err != nil {
return err
}
result, err := backend.PGPush(
ctx, target, cfg, projects, excludeProjects,
)
if err != nil {
return err
}
writePGPushSummary(os.Stdout, result)
if result.Errors > 0 {
return fmt.Errorf("%d session(s) failed", result.Errors)
}
return nil
}
// pgPushProgressStage buckets progress reports into the display stages that
// each own one progress line: daemon-side setup, fingerprinting, the session
// push, and the vector push.
func pgPushProgressStage(p postgres.PushProgress) string {
switch {
case p.Phase == "preparing" && p.SessionsTotal == 0:
return "setup"
case p.Phase == "preparing":
return "fingerprints"
case p.Phase == "vectors":
return "vectors"
default:
return "sessions"
}
}
// newPGPushProgressPrinter returns a progress renderer that updates the
// current stage's line in place and finishes it with a newline when the push
// moves to the next stage, so each completed stage stays in the scrollback
// instead of being overwritten by the next one. Every render carries the
// elapsed time since the printer was created (push start), so long stages
// show how long the push has been running. Renders end with an
// erase-to-end-of-line so a shorter render fully replaces a longer one
// instead of leaving its tail behind.
func newPGPushProgressPrinter() func(postgres.PushProgress) {
lastStage := ""
start := time.Now()
return func(p postgres.PushProgress) {
stage := pgPushProgressStage(p)
if lastStage != "" && stage != lastStage {
fmt.Println()
}
lastStage = stage
fmt.Printf("\r%s (%s elapsed)\x1b[K",
pgPushProgressLine(p), time.Since(start).Round(time.Second))
}
}
// pgPushProgressLine renders one progress report as the visible line text for
// its stage; newPGPushProgressPrinter owns the in-place terminal handling.
func pgPushProgressLine(p postgres.PushProgress) string {
if p.Phase == "preparing" {
if p.SessionsTotal == 0 {
return "Preparing push (sync state, metadata, fingerprints)..."
}
return fmt.Sprintf(
"Preparing... %d/%d sessions fingerprinted",
p.SessionsDone, p.SessionsTotal,
)
}
if p.Phase == "vectors" {
return fmt.Sprintf(
"Pushing vectors... %d/%d sessions scanned, %d chunks",
p.VectorSessionsDone, p.VectorSessionsTotal,
p.VectorChunksPushed,
)
}
if p.SkippedConflicts > 0 {
return fmt.Sprintf(
"Pushing... %d/%d sessions, %d messages, %d ownership conflicts skipped",
p.SessionsDone, p.SessionsTotal,
p.MessagesDone, p.SkippedConflicts,
)
}
return fmt.Sprintf(
"Pushing... %d/%d sessions, %d messages",
p.SessionsDone, p.SessionsTotal,
p.MessagesDone,
)
}
func writePGPushSummary(w io.Writer, result postgres.PushResult) {
dur := result.Duration.Round(time.Millisecond)
errSuffix := ""
if result.Errors > 0 {
errSuffix = fmt.Sprintf(", %d error(s)", result.Errors)
}
if result.SkippedConflicts > 0 {
fmt.Fprintf(
w,
"Pushed %d sessions, %d messages, skipped %d ownership conflict(s)%s in %s\n",
result.SessionsPushed,
result.MessagesPushed,
result.SkippedConflicts,
errSuffix,
dur,
)
fmt.Fprintf(
w,
"Warning: skipped %d session(s) owned by another PostgreSQL push marker\n",
result.SkippedConflicts,
)
writePGVectorPushSummary(w, result.Vectors)
return
}
fmt.Fprintf(
w,
"Pushed %d sessions, %d messages%s in %s\n",
result.SessionsPushed,
result.MessagesPushed,
errSuffix,
dur,
)
writePGVectorPushSummary(w, result.Vectors)
}
// writePGVectorPushSummary prints the vector push phase outcome: a skip
// reason when the phase did not run, otherwise per-session and per-doc
// counters. An ownership conflict count is surfaced separately, analogous to
// the session-level SkippedConflicts warning, since those sessions were left
// untouched on PG because another machine's marker owns them.
func writePGVectorPushSummary(w io.Writer, v postgres.VectorPushResult) {
if v.Skipped {
if v.SkippedReason != "" {
fmt.Fprintf(w, "Vectors: skipped (%s)\n", v.SkippedReason)
}
return
}
fmt.Fprintf(
w,
"Vectors: %d session(s) pushed, %d unchanged, %d docs, %d chunks\n",
v.SessionsPushed, v.SessionsUnchanged, v.DocsPushed, v.ChunksPushed,
)
if v.Conflicts > 0 {
fmt.Fprintf(
w,
"Warning: skipped %d vector session(s) owned by another PostgreSQL push marker\n",
v.Conflicts,
)
}
if v.SessionsDeferred > 0 {
fmt.Fprintf(
w,
"Warning: deferred vectors for %d session(s) whose session push failed; the next successful push sends them\n",
v.SessionsDeferred,
)
}
}
func runPGStatus(targetName string, cfg PGStatusConfig) error {
appCfg, err := config.LoadMinimal()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
if err := os.MkdirAll(appCfg.DataDir, 0o755); err != nil {
return fmt.Errorf("creating data dir: %w", err)
}
setupLogFile(appCfg.DataDir)
targets, err := resolvePGTargetSelections(
appCfg, targetName, cfg.AllTargets,
)
if err != nil {
return err
}
applyClassifierConfig(appCfg)
database, err := openReadOnlyDB(appCfg)
if err != nil {
log.Printf(
"warning: reading local pg status watermark: %v",
err,
)
database = nil
}
if database != nil {
defer database.Close()
}
var failures []string
for i, target := range targets {
if len(targets) > 1 || target.Name != "" {
if i > 0 {
fmt.Println()
}
fmt.Printf("Target: %s\n", target.label())
}
if err := runPGStatusTarget(database, appCfg, target, cfg); err != nil {
if len(targets) == 1 {
return err
}
failures = append(
failures,
fmt.Sprintf("%s: %v", target.label(), err),
)
fmt.Fprintf(
os.Stderr,
"warning: pg status target %s failed: %v\n",
target.label(), err,
)
}
}
if len(failures) > 0 {
return fmt.Errorf(
"%d pg target(s) failed: %s",
len(failures),
strings.Join(failures, "; "),
)
}
return nil
}
func runPGStatusTarget(
database *db.DB,
appCfg config.Config,
target pgTargetSelection,
cfg PGStatusConfig,
) error {
target, err := resolvePGTargetConfig(appCfg, target)
if err != nil {
return err
}
if target.PG.URL == "" {
return fmt.Errorf("url not configured")
}
projects, excludeProjects, err := resolvePushProjects(
target.PG,
PGPushConfig{
ProjectsFlag: cfg.ProjectsFlag,
ExcludeProjects: cfg.ExcludeProjects,
AllProjects: cfg.AllProjects,
},
)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(
context.Background(), os.Interrupt,
)
defer stop()
lastPush := ""
if database != nil {
lastPush, err = postgres.ReadLastPushAt(
database,
target.SyncStateTarget,
projects,
excludeProjects,
target.MigrateLegacySyncState,
)
if err != nil {
log.Printf(
"warning: reading last_push_at: %v", err,
)
lastPush = ""
}
}
status, err := postgres.ReadStatus(
ctx,
target.PG.URL,
target.PG.Schema,
target.PG.MachineName,
target.PG.AllowInsecure,
lastPush,
)
if err != nil {
return err
}
fmt.Printf("Machine: %s\n", status.Machine)
fmt.Printf("Last push: %s\n",
valueOrNever(status.LastPushAt))
fmt.Printf("PG sessions: %d\n", status.PGSessions)
fmt.Printf("PG messages: %d\n", status.PGMessages)
return nil
}
func loadPGServeConfig(cmd *cobra.Command) (config.Config, string, error) {
basePath, err := cmd.Flags().GetString("base-path")
if err != nil {
return config.Config{}, "", fmt.Errorf("reading base-path: %w", err)
}
cfg, err := config.LoadPGServePFlags(cmd.Flags())
if err != nil {
return config.Config{}, "", fmt.Errorf("loading config: %w", err)
}
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return config.Config{}, "", fmt.Errorf("creating data dir: %w", err)
}
return cfg, basePath, nil
}
type pgServeStartup struct {
cfg config.Config
ctx context.Context
rtOpts serveRuntimeOptions
srv *server.Server
cleanup func()
}
var preparePGServe = preparePGServeImpl
func preparePGServeImpl(appCfg config.Config, basePath string) (pgServeStartup, error) {
if err := validateServeConfig(appCfg); err != nil {
return pgServeStartup{}, fmt.Errorf("invalid serve config: %w", err)
}
pgCfg, err := appCfg.ResolvePG()
if err != nil {
return pgServeStartup{}, fmt.Errorf("pg serve: %w", err)
}
if pgCfg.URL == "" {
return pgServeStartup{}, errors.New("pg serve: url not configured")
}
applyClassifierConfig(appCfg)
store, err := postgres.NewStore(
pgCfg.URL, pgCfg.Schema, pgCfg.AllowInsecure,
)
if err != nil {
return pgServeStartup{}, fmt.Errorf("pg serve: %w", err)
}
cleanupStore := func() { _ = store.Close() }
if len(appCfg.CustomModelPricing) > 0 {
store.SetCustomPricing(appCfg.CustomModelPricing)
}
ctx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt, syscall.SIGTERM,
)
cleanup := func() {
stop()
cleanupStore()
}
if err := postgres.EnsureSchema(
ctx, store.DB(), pgCfg.Schema,
); err != nil {
if !postgres.IsReadOnlyError(err) {
cleanup()
return pgServeStartup{}, fmt.Errorf(
"pg serve: schema migration failed: %w", err,
)
}
}
if err := postgres.CheckSchemaCompat(
ctx, store.DB(),
); err != nil {
cleanup()
return pgServeStartup{}, fmt.Errorf(
"pg serve: schema incompatible: %w\n"+
"Drop and recreate the PG schema, then run "+
"'agentsview pg push --full' to repopulate",
err,
)
}
if err := postgres.CheckDataVersionCompat(
ctx, store.DB(),
); err != nil {
cleanup()
return pgServeStartup{}, fmt.Errorf("pg serve: %w", err)
}
if err := wirePGVectorSearch(ctx, appCfg, store, "pg serve"); err != nil {
cleanup()
return pgServeStartup{}, fmt.Errorf("pg serve: %w", err)
}
if err := store.DetectInsightGenerationAvailability(ctx); err != nil {
cleanup()
return pgServeStartup{}, fmt.Errorf(
"pg serve: probing insight generation capability: %w", err,
)
}
rtOpts := serveRuntimeOptions{
Mode: "pg-serve",
RequestedPort: appCfg.Port,
}
appCfg, err = prepareServeRuntimeConfig(appCfg, rtOpts)
if err != nil {
cleanup()
return pgServeStartup{}, fmt.Errorf("pg serve: %w", err)
}
opts := []server.Option{
server.WithVersion(server.VersionInfo{
Version: version,
Commit: commit,
BuildDate: buildDate,
ReadOnly: true,
InsightGenerationAvailable: store.InsightGenerationAvailable(),
}),
server.WithDataDir(appCfg.DataDir),
server.WithBaseContext(ctx),
}
if basePath != "" {
opts = append(opts, server.WithBasePath(basePath))
}
return pgServeStartup{
cfg: appCfg, ctx: ctx, rtOpts: rtOpts,
srv: server.New(appCfg, store, nil, opts...), cleanup: cleanup,
}, nil
}
func runPGServe(appCfg config.Config, basePath string) {
setupLogFile(appCfg.DataDir)
if appCfg.RequireAuth {
if err := appCfg.EnsureAuthToken(); err != nil {
fatal("pg serve: generating auth token: %v", err)
}
}
startup, err := preparePGServe(appCfg, basePath)
if err != nil {
fatal("%v", err)
}
defer startup.cleanup()
appCfg = startup.cfg
ctx := startup.ctx
rtOpts := startup.rtOpts
srv := startup.srv
rt, err := startServerWithOptionalCaddy(
ctx,
appCfg,
srv,
rtOpts,
)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
fatal("pg serve: %v", err)
}
// Write the kit runtime record so CLI commands can discover this
// daemon. ReadOnly=true marks it as pg serve (read-only)
// so clients can select an appropriate transport.
if writePGServeRuntimeRecord(rt) {
defer RemoveDaemonRuntime(rt.Cfg.DataDir)
}
if rt.Cfg.RequireAuth && rt.Cfg.AuthToken != "" {
fmt.Println("Auth enabled. Token is configured.")
}
if rt.PublicURL == rt.LocalURL {
fmt.Printf(
"agentsview %s (pg read-only) at %s\n",
version,
rt.LocalURL,
)
} else {
fmt.Printf(
"agentsview %s (pg read-only) backend at %s, public at %s\n",
version,
rt.LocalURL,
rt.PublicURL,
)
}
if err := waitForServerRuntime(ctx, srv, rt); err != nil {
fatal("pg serve: %v", err)
}
}
func writePGServeRuntimeRecord(rt *serveRuntime) bool {
if _, sfErr := writeDaemonRuntimeWithAuth(
rt.Cfg.DataDir, rt.Cfg.Host, rt.Cfg.Port, version, true,
rt.Cfg.RequireAuth,
rt.Caddy.Pid(),
); sfErr != nil {
reportRuntimeRecordWrite(
os.Stdout, sfErr,
"pg serve daemon may not be discoverable by CLI", "",
)
return false
}
return true
}
func resolvePushProjects(
pgCfg config.PGConfig, cfg PGPushConfig,
) (projects, exclude []string, err error) {
if cfg.ProjectsFlag != "" && cfg.ExcludeProjects != "" {
return nil, nil, fmt.Errorf(
"--projects and --exclude-projects are mutually exclusive",
)
}
if cfg.AllProjects &&
(cfg.ProjectsFlag != "" || cfg.ExcludeProjects != "") {
return nil, nil, fmt.Errorf(
"--all-projects cannot be combined with " +
"--projects or --exclude-projects",
)
}
projects = pgCfg.Projects
exclude = pgCfg.ExcludeProjects
if cfg.AllProjects {
projects = nil
exclude = nil
}
if cfg.ProjectsFlag != "" {
projects = splitProjectList(cfg.ProjectsFlag)
exclude = nil
}
if cfg.ExcludeProjects != "" {
exclude = splitProjectList(cfg.ExcludeProjects)
projects = nil
}
if len(projects) > 0 && len(exclude) > 0 {
return nil, nil, fmt.Errorf(
"projects and exclude_projects are mutually exclusive",
)
}
return projects, exclude, nil
}
func resolvePGTargetSelections(
appCfg config.Config,
targetName string,
allTargets bool,
) ([]pgTargetSelection, error) {
if allTargets && strings.TrimSpace(targetName) != "" {
return nil, fmt.Errorf(
"target name cannot be combined with --all",
)
}
if len(appCfg.PGTargets) == 0 {
if strings.TrimSpace(targetName) != "" {
return nil, fmt.Errorf(
"pg target %q is not configured; config uses a single legacy [pg] block",
targetName,
)
}
return []pgTargetSelection{{
IsDefault: true,
}}, nil
}
names, defaultName, err := appCfg.PGTargetNames()
if err != nil {
return nil, err
}
selections := make([]pgTargetSelection, 0, len(names))
for _, name := range names {
selection := pgTargetSelection{
Name: name,
IsDefault: name == defaultName,
SyncStateTarget: name,
MigrateLegacySyncState: name == defaultName,
}
selections = append(selections, selection)
}
if allTargets {
return selections, nil
}
normalizedTarget := strings.TrimSpace(
strings.ToLower(targetName),
)
if normalizedTarget == "" {
return selections[:1], nil
}
for _, target := range selections {
if target.Name == normalizedTarget {
return []pgTargetSelection{target}, nil
}
}
return nil, fmt.Errorf(
"pg target %q is not configured",
targetName,
)
}
func resolvePGTargetConfig(
appCfg config.Config,
target pgTargetSelection,
) (pgTargetSelection, error) {
var (
pgCfg config.PGConfig
err error
)
if target.Name == "" {
pgCfg, err = appCfg.ResolvePG()
} else {
pgCfg, err = appCfg.ResolvePGTarget(target.Name)
}
if err != nil {
return pgTargetSelection{}, err
}
target.PG = pgCfg
return target, nil
}
func splitProjectList(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}