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

340 lines
10 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 mcp
import (
"context"
"sync"
"time"
"github.com/mark3labs/mcp-go/mcp"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/graph"
)
// staleRefsBroadcaster fans `notifications/stale_refs` events to
// subscribed MCP sessions when the watcher's symbol-change callback
// reports that a re-indexed file removed or signature-mutated symbols
// the session has touched.
//
// "Touched" is read off the session's working set — the recently
// viewed / modified files, viewed symbols, and modified files
// recorded on sessionState (the same surface `recover_session`
// reads). A subscriber only ever sees notifications for paths /
// symbols *that session* has interacted with; another session's
// activity is invisible to it.
//
// Delivery shape: one notification per (session, file_path). When a
// re-index touches multiple sessions' working sets, each session
// gets its own filtered payload. A session whose working set has
// nothing in common with the changed file gets nothing.
//
// Delta filter: per (session, file_path) we hash the
// (removed_symbol_ids, changed_signature_ids) tuple and suppress
// identical re-emits. This avoids fanning out the same "x.go's
// HandleX was removed" payload on every subsequent save of x.go.
type staleRefsBroadcaster struct {
server specificNotificationSender
logger *zap.Logger
sessions *sessionMap // session lookup for per-session working sets
defaults *sessionState // embedded-mode fallback (no sessionMap entries)
mu sync.RWMutex
subscribers map[string]bool // session ID → subscribed
lastHash map[string]string // sessionID|path → last-emitted fingerprint
}
func newStaleRefsBroadcaster(srv specificNotificationSender, sessions *sessionMap, defaults *sessionState, logger *zap.Logger) *staleRefsBroadcaster {
if logger == nil {
logger = zap.NewNop()
}
return &staleRefsBroadcaster{
server: srv,
logger: logger,
sessions: sessions,
defaults: defaults,
subscribers: make(map[string]bool),
lastHash: make(map[string]string),
}
}
// handleSymbolChange is the watcher-callback target. For every
// subscriber whose working set intersects the change, emit a
// `notifications/stale_refs` payload.
//
// oldSymbols: symbols that were in the graph before this re-index
// (potentially removed or replaced). newSymbols: symbols present
// after this re-index. Removed = oldnew; signature-mutated = the
// intersection where Meta["signature"] differs.
func (b *staleRefsBroadcaster) handleSymbolChange(filePath string, oldSymbols, newSymbols []*graph.Node) {
if b == nil || b.server == nil || filePath == "" {
return
}
b.mu.RLock()
if len(b.subscribers) == 0 {
b.mu.RUnlock()
return
}
subs := make([]string, 0, len(b.subscribers))
for id := range b.subscribers {
subs = append(subs, id)
}
b.mu.RUnlock()
removedIDs, changedIDs := diffSymbolSets(oldSymbols, newSymbols)
if len(removedIDs) == 0 && len(changedIDs) == 0 {
return
}
for _, sid := range subs {
state := b.lookupSession(sid)
if state == nil {
continue
}
affectedRemoved, affectedChanged, viewedFile := state.intersectStaleness(filePath, removedIDs, changedIDs)
if !viewedFile && len(affectedRemoved) == 0 && len(affectedChanged) == 0 {
continue
}
key := sid + "|" + filePath
fp := hashStalePayload(affectedRemoved, affectedChanged, viewedFile)
b.mu.Lock()
if b.lastHash[key] == fp {
b.mu.Unlock()
continue
}
b.lastHash[key] = fp
b.mu.Unlock()
params := map[string]any{
"path": filePath,
"uri": pathToFileURI(filePath),
"viewed_file": viewedFile,
"removed_symbols": affectedRemoved,
"changed_signatures": affectedChanged,
"ts": time.Now().UTC().Format(time.RFC3339Nano),
}
if err := b.server.SendNotificationToSpecificClient(sid, "notifications/stale_refs", params); err != nil {
b.logger.Debug("send stale_refs failed",
zap.String("session", sid),
zap.String("path", filePath),
zap.Error(err))
}
}
}
// subscribe records sessionID. No initial-replay payload — by design.
// staleness is about deltas, and the session has nothing to "stale"
// against until it has consumed a symbol that later changes.
func (b *staleRefsBroadcaster) subscribe(sessionID string) {
if sessionID == "" {
return
}
b.mu.Lock()
b.subscribers[sessionID] = true
b.mu.Unlock()
}
func (b *staleRefsBroadcaster) unsubscribe(sessionID string) {
if sessionID == "" {
return
}
b.mu.Lock()
delete(b.subscribers, sessionID)
// Drop per-session delta cache so a future resubscribe gets a
// fresh notification on the first matching change instead of
// being silenced by an old hash.
for k := range b.lastHash {
if len(k) > len(sessionID) && k[:len(sessionID)] == sessionID && k[len(sessionID)] == '|' {
delete(b.lastHash, k)
}
}
b.mu.Unlock()
}
func (b *staleRefsBroadcaster) subscriberCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.subscribers)
}
// lookupSession returns the sessionState for sessionID. Falls back
// to the embedded-mode default when no sessionMap is wired (matches
// Server.sessionFor's shape).
func (b *staleRefsBroadcaster) lookupSession(sessionID string) *sessionState {
if b.sessions == nil {
return b.defaults
}
entry := b.sessions.get(sessionID)
if entry == nil {
return b.defaults
}
return entry.session
}
// diffSymbolSets computes (removed, signature-changed) ID slices
// from before/after symbol lists. KindFile / KindImport are skipped
// — clients care about user-authored symbol churn, not the file
// node's own bookkeeping mutation.
func diffSymbolSets(oldSymbols, newSymbols []*graph.Node) (removedIDs, changedIDs []string) {
oldMap := make(map[string]string, len(oldSymbols)) // ID → signature
for _, n := range oldSymbols {
if n == nil {
continue
}
if n.Kind == graph.KindFile || n.Kind == graph.KindImport {
continue
}
sig, _ := n.Meta["signature"].(string)
oldMap[n.ID] = sig
}
newMap := make(map[string]string, len(newSymbols))
for _, n := range newSymbols {
if n == nil {
continue
}
if n.Kind == graph.KindFile || n.Kind == graph.KindImport {
continue
}
sig, _ := n.Meta["signature"].(string)
newMap[n.ID] = sig
}
for id, oldSig := range oldMap {
newSig, exists := newMap[id]
switch {
case !exists:
removedIDs = append(removedIDs, id)
case oldSig != newSig:
changedIDs = append(changedIDs, id)
}
}
return removedIDs, changedIDs
}
// intersectStaleness reports the subset of removedIDs / changedIDs
// the session has touched, plus whether the changed file itself is
// in the session's recent file / modified set. Caller holds no
// state lock — this acquires sessionState's own.
func (ss *sessionState) intersectStaleness(filePath string, removedIDs, changedIDs []string) (affectedRemoved, affectedChanged []string, viewedFile bool) {
if ss == nil {
return nil, nil, false
}
ss.mu.Lock()
viewedSymbols := make(map[string]struct{}, len(ss.viewedSymbols))
for _, id := range ss.viewedSymbols {
viewedSymbols[id] = struct{}{}
}
for _, p := range ss.viewedFiles {
if p == filePath {
viewedFile = true
break
}
}
if !viewedFile {
for _, p := range ss.modifiedFiles {
if p == filePath {
viewedFile = true
break
}
}
}
ss.mu.Unlock()
for _, id := range removedIDs {
if _, ok := viewedSymbols[id]; ok {
affectedRemoved = append(affectedRemoved, id)
}
}
for _, id := range changedIDs {
if _, ok := viewedSymbols[id]; ok {
affectedChanged = append(affectedChanged, id)
}
}
return affectedRemoved, affectedChanged, viewedFile
}
// hashStalePayload produces a stable fingerprint for delta suppression.
// Order-independent: sorts the input slices before hashing so the
// same change reported twice in different orderings de-dupes.
func hashStalePayload(removed, changed []string, viewedFile bool) string {
if len(removed) == 0 && len(changed) == 0 && !viewedFile {
return ""
}
r := append([]string(nil), removed...)
c := append([]string(nil), changed...)
sortStrings(r)
sortStrings(c)
buf := make([]byte, 0, 64)
for _, s := range r {
buf = append(buf, 'R')
buf = append(buf, s...)
buf = append(buf, ';')
}
for _, s := range c {
buf = append(buf, 'C')
buf = append(buf, s...)
buf = append(buf, ';')
}
if viewedFile {
buf = append(buf, 'V')
}
return string(buf)
}
// sortStrings is an in-place insertion sort. Small slices (the
// affected sets are typically 0-3 entries), so the standard-library
// import-cost dominates.
func sortStrings(s []string) {
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j-1] > s[j]; j-- {
s[j-1], s[j] = s[j], s[j-1]
}
}
}
// registerStaleRefsTools wires the subscribe / unsubscribe MCP tools.
func (s *Server) registerStaleRefsTools() {
s.addTool(
mcp.NewTool("subscribe_stale_refs",
mcp.WithDescription("Opt the current MCP session into `notifications/stale_refs` push events. When the watcher re-indexes a file and the change touches a symbol your session has viewed / modified (or the file itself is in your recent set), you receive `{path, uri, viewed_file, removed_symbols, changed_signatures, ts}`. Per-session filter — another session's activity is invisible to you. No initial replay (staleness is a delta concept). Pair with `unsubscribe_stale_refs`."),
),
s.handleSubscribeStaleRefs,
)
s.addTool(
mcp.NewTool("unsubscribe_stale_refs",
mcp.WithDescription("Opt the current MCP session out of `notifications/stale_refs` push events. Idempotent."),
),
s.handleUnsubscribeStaleRefs,
)
}
func (s *Server) handleSubscribeStaleRefs(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.staleRefsBroadcaster == nil {
return mcp.NewToolResultError("stale_refs broadcaster is not configured"), nil
}
id := SessionIDFromContext(ctx)
if id == "" {
id = "embedded"
}
s.staleRefsBroadcaster.subscribe(id)
return s.respondJSONOrTOON(ctx, req, map[string]any{
"subscribed": true,
"session_id": id,
"subscribers": s.staleRefsBroadcaster.subscriberCount(),
})
}
func (s *Server) handleUnsubscribeStaleRefs(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.staleRefsBroadcaster == nil {
return mcp.NewToolResultError("stale_refs broadcaster is not configured"), nil
}
id := SessionIDFromContext(ctx)
if id == "" {
id = "embedded"
}
s.staleRefsBroadcaster.unsubscribe(id)
return s.respondJSONOrTOON(ctx, req, map[string]any{
"subscribed": false,
"session_id": id,
"subscribers": s.staleRefsBroadcaster.subscriberCount(),
})
}