Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

499 lines
15 KiB
Go

package daemon
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/indexer"
"github.com/zzet/gortex/internal/resolver"
)
// WireRemoteStitch activates cross-daemon proxy-edge minting end-to-end
// when the edges feature is enabled and the router carries a federator: it
// installs the evidence prober on the MultiIndexer (so cross-repo
// resolution mints proxy edges) and returns a ProxyHydrator for the read
// path. Returns nil (a no-op) when the feature is off, the router has no
// federator, or there is no MultiIndexer — leaving the daemon on the
// read-only fan-out only.
func WireRemoteStitch(router *Router, mi *indexer.MultiIndexer, g graph.Store, cfg config.FederationEdgesConfig, logger *zap.Logger) *ProxyHydrator {
if !cfg.IsEnabled() || router == nil || router.federator == nil || mi == nil || g == nil {
return nil
}
remotes := func() []ServerEntry { return router.EffectiveEnabledRemotes(nil) }
timeout := router.federator.cfg.PerRemoteTimeout
prober := NewProxyEdgeProber(router.federator, remotes, timeout, logger)
mi.SetRemoteStitch(prober, cfg.MaxNodes())
hydrator := NewProxyHydrator(g, router.federator.clientFor, remotes,
cfg.TTL(), cfg.Depth(), cfg.MaxNodes(), timeout, logger)
// Subscribe to each subgraph-capable remote's event stream so a graph
// change there evicts our cached proxies for it (daemon-lifetime).
fed := router.federator
hydrator.SubscribeRemoteEvents(context.Background(), func(ctx context.Context, cli *ServerClient) bool {
h, err := fed.health.get(ctx, cli, timeout)
return err == nil && h.HasCapability("subgraph")
})
return hydrator
}
// ProxyEdgeProber implements resolver.RemoteDeclarationProber for the
// proxy-edge mint path: it asks each enabled remote that advertises the
// `subgraph` capability whether it owns a declaration of `name`, via the
// existing find_declaration tool over POST /v1/tools/find_declaration.
// It reuses the Federator's shared client cache, health cache, and
// circuit breaker, so it inherits the bounded-deadline + breaker
// protection of the read-only fan-out.
type ProxyEdgeProber struct {
fed *Federator
remotes func() []ServerEntry // enabled-remote snapshot
timeout time.Duration
logger *zap.Logger
}
// NewProxyEdgeProber wires the prober to the Federator's plumbing and an
// enabled-remote snapshot. Constructed by the daemon entry point only
// when federation.edges.enabled.
func NewProxyEdgeProber(fed *Federator, remotes func() []ServerEntry, timeout time.Duration, logger *zap.Logger) *ProxyEdgeProber {
if logger == nil {
logger = zap.NewNop()
}
if timeout <= 0 {
timeout = 2 * time.Second
}
return &ProxyEdgeProber{fed: fed, remotes: remotes, timeout: timeout, logger: logger}
}
// ProbeDeclaration asks each subgraph-capable enabled remote whether it
// owns a declaration of name, returning the first positive hit (cheapest,
// deterministic by roster order). importHint is already the positive
// evidence the resolver required to call us at all; the remote
// confirmation is the second half.
func (p *ProxyEdgeProber) ProbeDeclaration(ctx context.Context, name, importHint string) (resolver.RemoteDecl, bool) {
if p == nil || p.fed == nil || name == "" || importHint == "" {
return resolver.RemoteDecl{}, false
}
body, _ := json.Marshal(map[string]any{"use_site": name})
for _, rem := range p.remotes() {
if p.fed.breaker.isOpen(rem.Slug) {
continue
}
cli, err := p.fed.clientFor(rem)
if err != nil {
continue
}
// Only probe remotes that advertise the subgraph capability;
// otherwise proxy-edge minting is skipped for this remote and
// the read path stays on the read-only fan-out.
h, herr := p.fed.health.get(ctx, cli, p.timeout)
if herr != nil || !h.HasCapability("subgraph") {
continue
}
rctx, cancel := context.WithTimeout(ctx, p.timeout)
out, status, err := cli.ProxyToolCtx(rctx, "find_declaration", body)
cancel()
if err != nil || status != http.StatusOK {
p.fed.breaker.fail(rem.Slug)
continue
}
if decl, ok := parseRemoteDecl(out, rem.Slug, name); ok {
return decl, true
}
}
return resolver.RemoteDecl{}, false
}
// parseRemoteDecl unwraps a find_declaration tool result and returns the
// first declaration whose Name matches name (a real declaration of the
// symbol, not a coincidental use site), mapped to a resolver.RemoteDecl.
func parseRemoteDecl(out []byte, slug, name string) (resolver.RemoteDecl, bool) {
toolJSON, _ := unwrapToolJSON(out)
var payload struct {
Declarations []struct {
Declaration *graph.Node `json:"declaration"`
} `json:"declarations"`
}
if err := json.Unmarshal(toolJSON, &payload); err != nil {
return resolver.RemoteDecl{}, false
}
for _, g := range payload.Declarations {
d := g.Declaration
if d == nil || d.Name != name {
continue
}
return resolver.RemoteDecl{
Slug: slug,
RemoteID: d.ID,
Kind: d.Kind,
RepoPrefix: d.RepoPrefix,
WorkspaceID: d.WorkspaceID,
File: d.FilePath,
Line: d.StartLine,
}, true
}
return resolver.RemoteDecl{}, false
}
// remoteSubGraph mirrors server.SubGraphResponse on the wire (a local
// copy avoids a daemon -> server import).
type remoteSubGraph struct {
Root *graph.Node `json:"root"`
Nodes []*graph.Node `json:"nodes"`
Edges []*graph.Edge `json:"edges"`
Stats struct {
FetchedAt time.Time `json:"fetched_at"`
Truncated bool `json:"truncated"`
} `json:"stats"`
}
// GetSubGraph fetches a node's FULL neighbour ring from a remote's
// GET /v1/subgraph (proxy-edge hydration). depth defaults to 1.
func (c *ServerClient) GetSubGraph(ctx context.Context, id string, depth int) (*remoteSubGraph, error) {
base, err := url.JoinPath(c.BaseURL, "v1", "subgraph")
if err != nil {
return nil, fmt.Errorf("join subgraph URL: %w", err)
}
q := url.Values{}
q.Set("id", id)
if depth > 0 {
q.Set("depth", strconv.Itoa(depth))
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"?"+q.Encode(), nil)
if err != nil {
return nil, fmt.Errorf("build subgraph request: %w", err)
}
if tok := c.resolveAuthToken(); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch subgraph from %q: %w", c.Entry.Slug, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("subgraph from %q: status %d", c.Entry.Slug, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read subgraph response: %w", err)
}
var out remoteSubGraph
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("decode subgraph from %q: %w", c.Entry.Slug, err)
}
return &out, nil
}
// ProxyHydrator lazily fills a proxy node's neighbour ring from the
// owning remote's /v1/subgraph. It lives in the daemon read path (not in
// graph.Graph, which has no HTTP knowledge); --oneshot and pure-local
// installs never construct one.
type ProxyHydrator struct {
graph graph.Store
clientFor func(ServerEntry) (*ServerClient, error)
remotes func() []ServerEntry
ttl time.Duration
depth int
budget int
timeout time.Duration
logger *zap.Logger
}
// NewProxyHydrator builds a hydrator. Constructed by the daemon entry
// point only when federation.edges.enabled.
func NewProxyHydrator(g graph.Store, clientFor func(ServerEntry) (*ServerClient, error), remotes func() []ServerEntry, ttl time.Duration, depth, budget int, timeout time.Duration, logger *zap.Logger) *ProxyHydrator {
if logger == nil {
logger = zap.NewNop()
}
if depth <= 0 {
depth = 1
}
if timeout <= 0 {
timeout = 2 * time.Second
}
return &ProxyHydrator{
graph: g, clientFor: clientFor, remotes: remotes,
ttl: ttl, depth: depth, budget: budget, timeout: timeout, logger: logger,
}
}
// Hydrate pulls one neighbour ring for a proxy node over /v1/subgraph,
// mints any newly-referenced proxy nodes (origin-namespaced), adds the
// edges with honest provenance, refreshes FetchedAt, and returns the
// number of edges added. No-op when the ring is fresh (within ttl) and
// already populated. Bounded by ctx and the proxy budget.
func (h *ProxyHydrator) Hydrate(ctx context.Context, proxyID string) (int, error) {
if h == nil || h.graph == nil {
return 0, nil
}
n := h.graph.GetNode(proxyID)
if n == nil || !graph.IsProxyNode(n) {
return 0, nil
}
if !n.FetchedAt.IsZero() && time.Since(n.FetchedAt) < h.ttl &&
len(h.graph.GetOutEdges(proxyID)) > 0 {
return 0, nil
}
slug := graph.ProxyOriginSlug(proxyID)
remoteID := graph.ProxyRemoteID(proxyID)
rem, ok := h.remoteForSlug(slug)
if !ok {
return 0, nil
}
cli, err := h.clientFor(rem)
if err != nil {
return 0, err
}
rctx, cancel := context.WithTimeout(ctx, h.timeout)
sg, err := cli.GetSubGraph(rctx, remoteID, h.depth)
cancel()
if err != nil {
return 0, err
}
// Mint a proxy node for each neighbour (origin-namespaced).
for _, rn := range sg.Nodes {
if rn == nil || rn.ID == "" || rn.ID == remoteID {
continue
}
pid := graph.ProxyNodeID(slug, rn.ID)
if h.graph.GetNode(pid) != nil {
continue
}
if h.budgetExceeded() {
h.logger.Warn("federation: proxy budget exceeded during hydration",
zap.String("slug", slug))
break
}
h.graph.AddNode(&graph.Node{
ID: pid, Kind: rn.Kind, Name: rn.Name,
FilePath: rn.FilePath, StartLine: rn.StartLine,
RepoPrefix: rn.RepoPrefix, WorkspaceID: rn.WorkspaceID,
Origin: "remote:" + slug, Stub: true, FetchedAt: time.Now(),
})
}
// Add the ring's edges, rewriting remote ids to proxy ids (the root
// maps back to the existing proxy id). Skip an edge whose endpoint we
// did not pull (it would dangle).
added := 0
for _, re := range sg.Edges {
if re == nil {
continue
}
from := h.proxyize(slug, re.From, remoteID, proxyID)
to := h.proxyize(slug, re.To, remoteID, proxyID)
if h.graph.GetNode(from) == nil || h.graph.GetNode(to) == nil {
continue
}
h.graph.AddEdge(&graph.Edge{
From: from, To: to, Kind: re.Kind,
Origin: graph.OriginTextMatched, CrossRepo: true,
})
added++
}
// Refresh the root proxy's FetchedAt (AddNode upserts).
refreshed := *n
refreshed.FetchedAt = time.Now()
h.graph.AddNode(&refreshed)
return added, nil
}
// EvictRemote marks every proxy node owned by slug stale (resets
// FetchedAt) so the next access re-hydrates against fresh remote data.
// Called on a graph_invalidated frame from that remote. The
// graph.Store has no node-removal primitive that targets the origin
// namespace cleanly, so staleness is expressed as a forced re-hydrate
// rather than a hard delete — same observable outcome (fresh data on the
// next read). Returns the number of proxy nodes invalidated.
func (h *ProxyHydrator) EvictRemote(slug string) int {
if h == nil || h.graph == nil || slug == "" {
return 0
}
count := 0
for _, n := range h.graph.AllNodes() {
if !graph.IsProxyNode(n) || graph.ProxyOriginSlug(n.ID) != slug {
continue
}
stale := *n
stale.FetchedAt = time.Time{}
h.graph.AddNode(&stale)
count++
}
return count
}
func (h *ProxyHydrator) proxyize(slug, remoteNodeID, rootRemoteID, rootProxyID string) string {
if remoteNodeID == rootRemoteID {
return rootProxyID
}
return graph.ProxyNodeID(slug, remoteNodeID)
}
func (h *ProxyHydrator) remoteForSlug(slug string) (ServerEntry, bool) {
for _, r := range h.remotes() {
if r.Slug == slug {
return r, true
}
}
return ServerEntry{}, false
}
func (h *ProxyHydrator) budgetExceeded() bool {
if h.budget <= 0 {
return false
}
count := 0
for _, n := range h.graph.AllNodes() {
if graph.IsProxyNode(n) {
count++
if count >= h.budget {
return true
}
}
}
return false
}
// StreamEvents connects to the remote's GET /v1/events SSE stream and
// calls onEvent for every graph-change frame, until ctx is cancelled or
// the stream ends. SSE is long-lived, so it uses a request without the
// client's 60s timeout (cancellation rides on ctx). Returns the
// connection/read error so the caller can back off and reconnect.
func (c *ServerClient) StreamEvents(ctx context.Context, onEvent func()) error {
u, err := url.JoinPath(c.BaseURL, "v1", "events")
if err != nil {
return fmt.Errorf("join events URL: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return fmt.Errorf("build events request: %w", err)
}
if tok := c.resolveAuthToken(); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
req.Header.Set("Accept", "text/event-stream")
// Reuse the client's transport (so unix-socket remotes still work) but
// drop the request timeout — an SSE stream must outlive 60s.
sse := &http.Client{Transport: c.httpClient.Transport}
resp, err := sse.Do(req)
if err != nil {
return fmt.Errorf("subscribe events on %q: %w", c.Entry.Slug, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("events from %q: status %d", c.Entry.Slug, resp.StatusCode)
}
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
// Each graph-change SSE frame opens with `event: graph_change`.
if strings.HasPrefix(sc.Text(), "event: graph_change") {
onEvent()
}
}
return sc.Err()
}
// SubscribeRemoteEvents starts background per-remote /v1/events
// subscriptions: whenever a subgraph-capable enabled remote's graph
// changes, this daemon's cached proxy nodes for that remote are evicted
// (marked stale) so the next access re-hydrates against fresh data. Runs
// until ctx is cancelled; reconnects with backoff on a dropped stream and
// reconciles the subscription set as the enabled-remote roster changes.
// capable reports whether a remote advertises the subgraph capability.
func (h *ProxyHydrator) SubscribeRemoteEvents(ctx context.Context, capable func(context.Context, *ServerClient) bool) {
if h == nil || h.remotes == nil {
return
}
go func() {
subs := map[string]context.CancelFunc{}
reconcile := func() {
want := map[string]ServerEntry{}
for _, r := range h.remotes() {
want[r.Slug] = r
}
for slug, cancel := range subs {
if _, ok := want[slug]; !ok {
cancel()
delete(subs, slug)
}
}
for slug, rem := range want {
if _, ok := subs[slug]; ok {
continue
}
cli, err := h.clientFor(rem)
if err != nil {
continue
}
if capable != nil && !capable(ctx, cli) {
continue
}
sctx, cancel := context.WithCancel(ctx)
subs[slug] = cancel
go h.runRemoteSubscription(sctx, rem)
}
}
reconcile()
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
for _, cancel := range subs {
cancel()
}
return
case <-t.C:
reconcile()
}
}
}()
}
// runRemoteSubscription holds one remote's event subscription open,
// evicting that remote's proxies on every change, and reconnects after a
// short backoff when the stream drops.
func (h *ProxyHydrator) runRemoteSubscription(ctx context.Context, rem ServerEntry) {
for {
if ctx.Err() != nil {
return
}
if cli, err := h.clientFor(rem); err == nil {
_ = cli.StreamEvents(ctx, func() {
if n := h.EvictRemote(rem.Slug); n > 0 {
h.logger.Info("federation: evicted cached proxies on remote change",
zap.String("slug", rem.Slug), zap.Int("count", n))
}
})
}
select {
case <-ctx.Done():
return
case <-time.After(2 * time.Second):
}
}
}