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

287 lines
9.2 KiB
Go

package mcp
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"sync"
"time"
"github.com/mark3labs/mcp-go/mcp"
"go.uber.org/zap"
)
// readinessBroadcaster fans `notifications/workspace_readiness` push
// events to subscribed MCP sessions. Where `diagnostics` is event-
// driven off LSP publishDiagnostics, readiness is phase-driven off the
// daemon warmup pipeline: snapshot_loaded → parallel_parse → resolve →
// ready (references resolved, graph queryable) → deferred_passes_all →
// global_resolve → end_batch → watcher_started → enrichment_complete,
// plus steady-state ticks when re-indexing finishes. `ready` flips true
// at the `ready` phase — ahead of enrichment — and stays true; the later
// phases carry ready:true and report enrichment progress.
//
// Sessions opt in via `subscribe_workspace_readiness`. The current
// state is replayed immediately as `initial_replay: true` so a freshly
// connected client knows where the daemon is in its warmup curve
// without waiting for the next transition. Delta-filtered by payload
// hash so a no-op republish never fans out.
type readinessBroadcaster struct {
server specificNotificationSender
logger *zap.Logger
mu sync.RWMutex
subscribers map[string]bool // session ID → subscribed
state map[string]any // last published payload (nil until first publish)
lastHash string
}
func newReadinessBroadcaster(srv specificNotificationSender, logger *zap.Logger) *readinessBroadcaster {
if logger == nil {
logger = zap.NewNop()
}
return &readinessBroadcaster{
server: srv,
logger: logger,
subscribers: make(map[string]bool),
}
}
// publish updates the broadcaster's last-known state and fans the
// payload to every subscriber. Idempotent under delta filter: a
// payload identical to the last published one is suppressed. Always
// stamps `ts` (RFC3339Nano) on the wire payload.
//
// The caller-supplied payload map is copied before storage so a
// subsequent caller-side mutation can't race the next subscribe replay.
func (b *readinessBroadcaster) publish(payload map[string]any) {
if b == nil || b.server == nil {
return
}
out := copyPayload(payload)
out["ts"] = time.Now().UTC().Format(time.RFC3339Nano)
hash := hashPayload(out)
b.mu.Lock()
if b.lastHash == hash {
b.mu.Unlock()
return
}
b.lastHash = hash
b.state = out
subs := make([]string, 0, len(b.subscribers))
for id := range b.subscribers {
subs = append(subs, id)
}
b.mu.Unlock()
for _, sid := range subs {
if err := b.server.SendNotificationToSpecificClient(sid, "notifications/workspace_readiness", out); err != nil {
b.logger.Debug("send workspace_readiness failed",
zap.String("session", sid), zap.Error(err))
}
}
}
// subscribe records sessionID and immediately delivers the last-known
// state (if any) as an initial replay. Returns true when a replay
// payload was sent.
func (b *readinessBroadcaster) subscribe(sessionID string) bool {
if sessionID == "" {
return false
}
b.mu.Lock()
b.subscribers[sessionID] = true
var replay map[string]any
if b.state != nil {
replay = copyPayload(b.state)
replay["initial_replay"] = true
}
b.mu.Unlock()
if replay == nil {
return false
}
if err := b.server.SendNotificationToSpecificClient(sessionID, "notifications/workspace_readiness", replay); err != nil {
b.logger.Debug("workspace_readiness initial replay failed",
zap.String("session", sessionID), zap.Error(err))
return false
}
return true
}
func (b *readinessBroadcaster) unsubscribe(sessionID string) {
if sessionID == "" {
return
}
b.mu.Lock()
delete(b.subscribers, sessionID)
b.mu.Unlock()
}
func (b *readinessBroadcaster) subscriberCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.subscribers)
}
// snapshot returns the last-known state for inclusion in status /
// debug surfaces. nil when no publish has happened yet.
func (b *readinessBroadcaster) snapshot() map[string]any {
b.mu.RLock()
defer b.mu.RUnlock()
if b.state == nil {
return nil
}
return copyPayload(b.state)
}
// copyPayload returns a shallow copy of a notification map. We only
// store scalars / slices / nested maps in these payloads; the call
// sites never put pointers behind values, so a shallow copy is
// enough to keep mutations from racing the next replay.
func copyPayload(p map[string]any) map[string]any {
if p == nil {
return nil
}
out := make(map[string]any, len(p))
for k, v := range p {
out[k] = v
}
return out
}
// hashPayload derives a stable fingerprint for delta filtering. Map
// keys are sorted before encoding so two equivalent payloads with
// different insertion orders hash identically.
func hashPayload(p map[string]any) string {
if len(p) == 0 {
return "empty"
}
keys := make([]string, 0, len(p))
for k := range p {
// `ts` is monotonic — every publish would mismatch on it
// alone, defeating the delta filter. Exclude it from the
// fingerprint so identical content republishes are
// suppressed regardless of timestamp.
if k == "ts" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
buf := make([]byte, 0, 128)
for _, k := range keys {
buf = append(buf, k...)
buf = append(buf, '=')
enc, err := json.Marshal(p[k])
if err != nil {
buf = append(buf, "?"...)
} else {
buf = append(buf, enc...)
}
buf = append(buf, ';')
}
sum := sha256.Sum256(buf)
return hex.EncodeToString(sum[:])
}
// PublishReadiness is the public entry point the daemon (or in-
// process server) calls at warmup-phase transitions. Safe to call
// before any subscriber has connected — the broadcaster keeps the
// last-known state so a late subscriber gets it as initial replay.
//
// Reserved fields automatically stamped on the wire payload:
// - `phase` (caller-supplied)
// - `ready` (caller-supplied bool)
// - `ts` (server-stamped RFC3339Nano)
//
// Empty phase is rejected — phase is the load-bearing field.
func (s *Server) PublishReadiness(phase string, ready bool, extra map[string]any) {
if s == nil || s.readinessBroadcaster == nil || phase == "" {
return
}
payload := make(map[string]any, len(extra)+2)
for k, v := range extra {
payload[k] = v
}
payload["phase"] = phase
payload["ready"] = ready
s.readinessBroadcaster.publish(payload)
}
// ReadinessPhase returns the daemon's last-published warmup phase and whether
// warmup has completed, read from the readiness broadcaster's last-known
// state. Returns ("", false) before the first publish. Used by the daemon to
// stamp the handshake ack (HandshakeAck.WarmupPhase) so a connecting client
// sees where the daemon is on its warmup curve.
func (s *Server) ReadinessPhase() (phase string, ready bool) {
if s == nil || s.readinessBroadcaster == nil {
return "", false
}
snap := s.readinessBroadcaster.snapshot()
if snap == nil {
return "", false
}
if p, ok := snap["phase"].(string); ok {
phase = p
}
if r, ok := snap["ready"].(bool); ok {
ready = r
}
return phase, ready
}
// registerReadinessTools wires the subscribe / unsubscribe MCP tools
// for the workspace_readiness channel.
func (s *Server) registerReadinessTools() {
s.addTool(
mcp.NewTool("subscribe_workspace_readiness",
mcp.WithDescription("Opt the current MCP session into `notifications/workspace_readiness` push events. Once subscribed, every daemon warmup-phase transition (snapshot_loaded → parallel_parse → resolve → ready → deferred_passes_all → global_resolve → end_batch → watcher_started → enrichment_complete) plus steady-state re-index completions are pushed to your session as `{phase, ready, ts, ...}`. `ready` flips true at the `ready` phase — once references are resolved and the graph is queryable — ahead of the slower semantic enrichment, which completes at `enrichment_complete`. The last-known state is replayed immediately as `initial_replay: true` so a freshly connected client knows where the daemon is on its warmup curve. Pair with `unsubscribe_workspace_readiness` to opt back out."),
),
s.handleSubscribeReadiness,
)
s.addTool(
mcp.NewTool("unsubscribe_workspace_readiness",
mcp.WithDescription("Opt the current MCP session out of `notifications/workspace_readiness` push events. Idempotent."),
),
s.handleUnsubscribeReadiness,
)
}
func (s *Server) handleSubscribeReadiness(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.readinessBroadcaster == nil {
return mcp.NewToolResultError("workspace_readiness broadcaster is not configured"), nil
}
id := SessionIDFromContext(ctx)
if id == "" {
id = "embedded"
}
replayed := s.readinessBroadcaster.subscribe(id)
return s.respondJSONOrTOON(ctx, req, map[string]any{
"subscribed": true,
"session_id": id,
"subscribers": s.readinessBroadcaster.subscriberCount(),
"replayed": replayed,
})
}
func (s *Server) handleUnsubscribeReadiness(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.readinessBroadcaster == nil {
return mcp.NewToolResultError("workspace_readiness broadcaster is not configured"), nil
}
id := SessionIDFromContext(ctx)
if id == "" {
id = "embedded"
}
s.readinessBroadcaster.unsubscribe(id)
return s.respondJSONOrTOON(ctx, req, map[string]any{
"subscribed": false,
"session_id": id,
"subscribers": s.readinessBroadcaster.subscriberCount(),
})
}