已合并
Expose bounded shared daemon observations across Admin, IPC, CLI and MCP #169
urandon创建于 25 天前
Expose bounded shared daemon observations across Admin, IPC, CLI and MCP #169
已合并
urandon创建于 25 天前
共 45 个文件变更+4944-240
@@ -226,7 +226,7 @@ see [MCP Setup](docs/mcp-setup.md).
226| First live repository | [Live readiness](docs/live-readiness.md) · [Repository binding](docs/repo-binding.md) |226| First live repository | [Live readiness](docs/live-readiness.md) · [Repository binding](docs/repo-binding.md) |
227| Agent integration | [MCP setup](docs/mcp-setup.md) · [Read walkthrough](docs/read-walkthrough.md) · [Write walkthrough](docs/write-walkthrough.md) |227| Agent integration | [MCP setup](docs/mcp-setup.md) · [Read walkthrough](docs/read-walkthrough.md) · [Write walkthrough](docs/write-walkthrough.md) |
228| Search and RAG | [RAG setup](docs/rag.md) · [Repository documentation RAG](docs/repository-documentation-rag.md) |228| Search and RAG | [RAG setup](docs/rag.md) · [Repository documentation RAG](docs/repository-documentation-rag.md) |
229-| Operate the service | [Cache maintenance](docs/cache-maintenance.md) · [Admin UI](docs/admin-ui.md) · [Cache and sync model](docs/cache-and-sync-model.md) |229+| Operate the service | [Cache maintenance](docs/cache-maintenance.md) · [Daemon observation](docs/daemon-observation.md) · [Admin UI](docs/admin-ui.md) · [Cache and sync model](docs/cache-and-sync-model.md) |
230| Understand the system | [Architecture](docs/architecture.md) · [Component architecture](docs/component-architecture.md) · [Test architecture](docs/test-architecture.md) |230| Understand the system | [Architecture](docs/architecture.md) · [Component architecture](docs/component-architecture.md) · [Test architecture](docs/test-architecture.md) |
231| Contribute and release | [Agent guide](AGENTS.md) · [PR/MR workflow](docs/pr-mr-workflow.md) · [Release process](docs/release-process.md) |231| Contribute and release | [Agent guide](AGENTS.md) · [PR/MR workflow](docs/pr-mr-workflow.md) · [Release process](docs/release-process.md) |
232| Safety and evidence | [Sanitization](docs/sanitization.md) · [GitCode API discovery](docs/gitcode-api-discovery.md) · [Structured feedback](docs/feedback.md) |232| Safety and evidence | [Sanitization](docs/sanitization.md) · [GitCode API discovery](docs/gitcode-api-discovery.md) · [Structured feedback](docs/feedback.md) |
@@ -2,7 +2,6 @@ package main
2 2 
3import (3import (
4 "context"4 "context"
5- "errors"
6 "fmt"5 "fmt"
7 "io"6 "io"
8 "os"7 "os"
@@ -112,6 +111,36 @@ func run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer, src
112 printMCPHelp(stdout)111 printMCPHelp(stdout)
113 return 0112 return 0
114 }113 }
114+ if cli.IsObservationCommand(rest) {
115+ formats := 0
116+ for i := 0; i < len(args)-len(rest); i++ {
117+ if args[i] == "--" {
118+ continue
119+ }
120+ if args[i] == "--format" {
121+ formats++
122+ i++
123+ continue
124+ }
125+ if strings.HasPrefix(args[i], "--format=") {
126+ formats++
127+ continue
128+ }
129+ fmt.Fprintln(stderr, "startup: observation accepts only --format before the command")
130+ return 2
131+ }
132+ if formats > 1 {
133+ fmt.Fprintln(stderr, "startup: repeated observation format flag")
134+ return 2
135+ }
136+ localArgs := append([]string(nil), rest...)
137+ if opts.overrides.Format != "" {
138+ localArgs = append(localArgs, "--format", opts.overrides.Format)
139+ }
140+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
141+ defer cancel()
142+ return cli.ExecuteWithSourceContext(ctx, localArgs, stdout, stderr, src)
143+ }
115 if len(rest) > 0 && (rest[0] == "config" || rest[0] == "auth" || rest[0] == "doctor" || rest[0] == "migrate-cache") {144 if len(rest) > 0 && (rest[0] == "config" || rest[0] == "auth" || rest[0] == "doctor" || rest[0] == "migrate-cache") {
116 localArgs := append([]string(nil), rest...)145 localArgs := append([]string(nil), rest...)
117 if (rest[0] == "auth" || rest[0] == "doctor") && opts.live && !hasCLIFlag(localArgs[1:], "--live") {146 if (rest[0] == "auth" || rest[0] == "doctor") && opts.live && !hasCLIFlag(localArgs[1:], "--live") {
@@ -133,16 +162,16 @@ func run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer, src
133 }162 }
134 cfg := eff.Config163 cfg := eff.Config
135 credentialResolver := auth.NewCredentialResolver(src)164 credentialResolver := auth.NewCredentialResolver(src)
136- token := config.Token(src)165+ token := ""
166+ if !opts.mcp && !opts.mcpServe {
167+ token = config.Token(src)
168+ }
137 live := opts.live169 live := opts.live
138- if opts.live || opts.mcp || opts.mcpServe {170+ if opts.live && !opts.mcp && !opts.mcpServe {
139 credential := credentialResolver.Resolve(context.Background(), eff)171 credential := credentialResolver.Resolve(context.Background(), eff)
140 if strings.TrimSpace(credential.Token) != "" {172 if strings.TrimSpace(credential.Token) != "" {
141 token = credential.Token173 token = credential.Token
142 }174 }
143- if (opts.mcp || opts.mcpServe) && !opts.offline && credential.Present && strings.TrimSpace(credential.Token) != "" {
144- live = true
145- }
146 }175 }
147 deps := buildStartupDeps(cfg, token, live)176 deps := buildStartupDeps(cfg, token, live)
148 deps.GitCode.Offline = opts.offline177 deps.GitCode.Offline = opts.offline
@@ -504,87 +533,20 @@ func runMCPStdio(ctx context.Context, stdin io.Reader, stdout io.Writer, stderr
504}533}
505 534 
506func newMCPStdioServer(ctx context.Context, stdin io.Reader, stdout io.Writer, stderr io.Writer, deps StartupDeps) (*mcp.Server, func()) {535func newMCPStdioServer(ctx context.Context, stdin io.Reader, stdout io.Writer, stderr io.Writer, deps StartupDeps) (*mcp.Server, func()) {
507- if err := ensureParentDir(deps.Config.CachePath); err != nil {536+ bootstrap := newMCPBootstrap(deps)
508- server := mcp.NewMinimal(stdin, stdout, stderr, mcp.StartupDiagnosticFromError(err))537+ server := mcp.NewWithToolAccess(stdin, stdout, stderr, nil, deps.CredentialResolver, mcp.ToolAccess(deps.Config.MCPToolAccess))
509- server.SetRuntimeContext(mcpRuntimeContext(deps))538+ server.SetToolBootstrap(bootstrap.ForTool)
510- return server, func() {}539+ server.SetBootstrapDiagnostic(bootstrap.Diagnostic)
511- }540+ server.SetServiceClient(bootstrap.ServiceClient)
512- store, err := cache.NewSQLiteStore(ctx, deps.Config.CachePath)
513- if err != nil {
514- server := mcp.NewMinimal(stdin, stdout, stderr, mcp.StartupDiagnosticFromError(err))
515- server.SetRuntimeContext(mcpRuntimeContext(deps))
516- return server, func() {}
517- }
518- svc, err := resolveService(store, deps)
519- if err != nil {
520- _ = store.Close()
521- server := mcp.NewMinimal(stdin, stdout, stderr, mcp.StartupDiagnosticFromError(err))
522- server.SetRuntimeContext(mcpRuntimeContext(deps))
523- return server, func() {}
524- }
525- server := mcp.NewWithToolAccess(stdin, stdout, stderr, svc, deps.CredentialResolver, mcp.ToolAccess(deps.Config.MCPToolAccess))
526- server.SetRAGStatusProvider(newMCPRAGStatusProvider(store, deps))
527- server.SetRAGSearchProvider(newMCPRAGSearchProvider(store, deps))
528- setMCPRepositoryDocsProviders(server.SetRepositoryDocsProviders, store, deps)
529 server.SetRuntimeContext(mcpRuntimeContext(deps))541 server.SetRuntimeContext(mcpRuntimeContext(deps))
530 setMCPMaintenanceProviders(server.SetMaintenanceProviders, deps)542 setMCPMaintenanceProviders(server.SetMaintenanceProviders, deps)
531- return server, func() { _ = store.Close() }543+ return server, bootstrap.Close
532}544}
533 545 
534func runMCPHTTPSSE(ctx context.Context, stderr io.Writer, deps StartupDeps, bind string) int {546func runMCPHTTPSSE(ctx context.Context, stderr io.Writer, deps StartupDeps, bind string) int {
535- if err := ensureParentDir(deps.Config.CachePath); err != nil {547+ handler, bootstrap := newMCPHTTPHandler(deps)
536- handler := mcp.NewMinimalRPCHandler(mcp.StartupDiagnosticFromError(err))548+ defer bootstrap.Close()
537- handler.SetRuntimeContext(mcpRuntimeContext(deps))549+ transport := mcp.NewHTTPSSETransport(handler, mcp.ServerConfig{BindAddress: bind, ReadinessProbe: bootstrap.Readiness})
538- transport := mcp.NewHTTPSSETransport(handler, mcp.ServerConfig{BindAddress: bind})
539- if err := transport.Serve(ctx); err != nil {
540- fmt.Fprintln(stderr, config.RedactDiagnostic(err.Error(), config.OSSource{}))
541- return 1
542- }
543- return 0
544- }
545- store, err := cache.NewSQLiteStore(ctx, deps.Config.CachePath)
546- if err != nil {
547- handler := mcp.NewMinimalRPCHandler(mcp.StartupDiagnosticFromError(err))
548- handler.SetRuntimeContext(mcpRuntimeContext(deps))
549- transport := mcp.NewHTTPSSETransport(handler, mcp.ServerConfig{BindAddress: bind})
550- if err := transport.Serve(ctx); err != nil {
551- fmt.Fprintln(stderr, config.RedactDiagnostic(err.Error(), config.OSSource{}))
552- return 1
553- }
554- return 0
555- }
556- defer store.Close()
557- svc, err := resolveService(store, deps)
558- if err != nil {
559- handler := mcp.NewMinimalRPCHandler(mcp.StartupDiagnosticFromError(err))
560- handler.SetRuntimeContext(mcpRuntimeContext(deps))
561- transport := mcp.NewHTTPSSETransport(handler, mcp.ServerConfig{BindAddress: bind})
562- if err := transport.Serve(ctx); err != nil {
563- fmt.Fprintln(stderr, config.RedactDiagnostic(err.Error(), config.OSSource{}))
564- return 1
565- }
566- return 0
567- }
568- handler := mcp.NewRPCHandlerWithCredentialResolverAndToolAccess(svc, deps.CredentialResolver, mcp.ToolAccess(deps.Config.MCPToolAccess))
569- handler.SetRAGStatusProvider(newMCPRAGStatusProvider(store, deps))
570- handler.SetRAGSearchProvider(newMCPRAGSearchProvider(store, deps))
571- setMCPRepositoryDocsProviders(handler.SetRepositoryDocsProviders, store, deps)
572- handler.SetRuntimeContext(mcpRuntimeContext(deps))
573- setMCPMaintenanceProviders(handler.SetMaintenanceProviders, deps)
574- transport := mcp.NewHTTPSSETransport(handler, mcp.ServerConfig{BindAddress: bind, ReadinessProbe: func(ctx context.Context) mcp.Readiness {
575- repos, err := store.ListRepositories(ctx)
576- if err != nil {
577- var lockErr cache.ErrLockContention
578- if errors.As(err, &lockErr) {
579- return mcp.LockContentionReadiness(lockErr)
580- }
581- return mcp.Readiness{Ready: false, Code: "cache_unreadable", Message: err.Error()}
582- }
583- if len(repos) == 0 {
584- return mcp.Readiness{Ready: false, Code: "repo_unavailable", Message: "no repositories configured"}
585- }
586- return mcp.Readiness{Ready: true}
587- }})
588 if err := transport.Serve(ctx); err != nil {550 if err := transport.Serve(ctx); err != nil {
589 fmt.Fprintln(stderr, config.RedactDiagnostic(err.Error(), config.OSSource{}))551 fmt.Fprintln(stderr, config.RedactDiagnostic(err.Error(), config.OSSource{}))
590 return 1552 return 1
@@ -1160,7 +1160,7 @@ func TestEntrypointMCPStartupFallback(t *testing.T) {
1160 if len(lines) != 2 {1160 if len(lines) != 2 {
1161 t.Fatalf("stdout lines=%d %q", len(lines), stdout.String())1161 t.Fatalf("stdout lines=%d %q", len(lines), stdout.String())
1162 }1162 }
1163- assertStartupToolsList(t, lines[0], "schema_incompatible")1163+ assertStartupToolsList(t, lines[0], "")
1164 assertStartupDoctor(t, lines[1], "schema_incompatible", "upgrade")1164 assertStartupDoctor(t, lines[1], "schema_incompatible", "upgrade")
1165 })1165 })
1166 1166 
@@ -1179,7 +1179,7 @@ func TestEntrypointMCPStartupFallback(t *testing.T) {
1179 if len(lines) != 2 {1179 if len(lines) != 2 {
1180 t.Fatalf("stdout lines=%d %q", len(lines), stdout.String())1180 t.Fatalf("stdout lines=%d %q", len(lines), stdout.String())
1181 }1181 }
1182- assertStartupToolsList(t, lines[0], "startup-failure")1182+ assertStartupToolsList(t, lines[0], "")
1183 assertStartupDoctor(t, lines[1], "startup-failure", "doctor")1183 assertStartupDoctor(t, lines[1], "startup-failure", "doctor")
1184 })1184 })
1185}1185}
@@ -1230,8 +1230,14 @@ func assertStartupToolsList(t *testing.T, line string, wantCode string) {
1230 if err := json.Unmarshal([]byte(line), &resp); err != nil {1230 if err := json.Unmarshal([]byte(line), &resp); err != nil {
1231 t.Fatalf("decode tools/list: %v line=%q", err, line)1231 t.Fatalf("decode tools/list: %v line=%q", err, line)
1232 }1232 }
1233- if len(resp.Result.Tools) != 1 || resp.Result.Tools[0].Name != "doctor" {1233+ names := map[string]bool{}
1234- t.Fatalf("tools/list tools=%+v", resp.Result.Tools)1234+ for _, tool := range resp.Result.Tools {
1235+ names[tool.Name] = true
1236+ }
1237+ for _, name := range []string{"doctor", "service_observability", "service_metrics", "service_events"} {
1238+ if !names[name] {
1239+ t.Fatalf("tools/list missing %s", name)
1240+ }
1235 }1241 }
1236 if resp.Result.StartupDiagnostic.ErrorClass != wantCode {1242 if resp.Result.StartupDiagnostic.ErrorClass != wantCode {
1237 t.Fatalf("diagnostic=%q want %q", resp.Result.StartupDiagnostic.ErrorClass, wantCode)1243 t.Fatalf("diagnostic=%q want %q", resp.Result.StartupDiagnostic.ErrorClass, wantCode)
@@ -1328,7 +1334,7 @@ func TestEntrypointMCPServeLiveFlagRouting(t *testing.T) {
1328 }1334 }
1329}1335}
1330 1336 
1331-func TestEntrypointMCPAutoLiveWithKeychainCredential(t *testing.T) {1337+func TestEntrypointMCPDefersKeychainAndLiveProviderUntilRequestedTool(t *testing.T) {
1332 src := newTestSource(t)1338 src := newTestSource(t)
1333 src.env["GITCODE_MCP_TEST_KEYCHAIN_TOKEN"] = "test-token"1339 src.env["GITCODE_MCP_TEST_KEYCHAIN_TOKEN"] = "test-token"
1334 old := mcpRoute1340 old := mcpRoute
@@ -1343,8 +1349,8 @@ func TestEntrypointMCPAutoLiveWithKeychainCredential(t *testing.T) {
1343 if code != 0 {1349 if code != 0 {
1344 t.Fatalf("exit = %d stderr=%q", code, stderr.String())1350 t.Fatalf("exit = %d stderr=%q", code, stderr.String())
1345 }1351 }
1346- if !got.GitCode.Live || got.GitCode.Token != "test-token" {1352+ if got.GitCode.Live || got.GitCode.Token != "" {
1347- t.Fatalf("expected MCP auto-live with keychain token, got %#v", got.GitCode)1353+ t.Fatal("MCP startup resolved a credential or selected a live provider before a requested operation")
1348 }1354 }
1349 if strings.Contains(stdout.String()+stderr.String(), "test-token") {1355 if strings.Contains(stdout.String()+stderr.String(), "test-token") {
1350 t.Fatalf("token emitted stdout=%q stderr=%q", stdout.String(), stderr.String())1356 t.Fatalf("token emitted stdout=%q stderr=%q", stdout.String(), stderr.String())
@@ -0,0 +1,158 @@
1+package main
2+ 
3+import (
4+ "context"
5+ "encoding/json"
6+ "strings"
7+ "sync"
8+ 
9+ "gitcode-mcp/internal/auth"
10+ "gitcode-mcp/internal/cache"
11+ "gitcode-mcp/internal/capability"
12+ "gitcode-mcp/internal/config"
13+ "gitcode-mcp/internal/mcp"
14+ "gitcode-mcp/internal/observability"
15+ "gitcode-mcp/internal/servicectl"
16+)
17+ 
18+// Only an explicitly requested cache/provider tool opens this store. Observation
19+// and discovery never take mu, so a blocked bootstrap cannot block diagnosis.
20+type mcpBootstrap struct {
21+ active sync.WaitGroup
22+ mu sync.Mutex
23+ deps StartupDeps
24+ store *cache.SQLiteStore
25+ closed bool
26+ diagnostic mcp.StartupDiagnostic
27+}
28+ 
29+func newMCPBootstrap(deps StartupDeps) *mcpBootstrap { return &mcpBootstrap{deps: deps} }
30+func newMCPHTTPHandler(deps StartupDeps) (*mcp.RPCHandler, *mcpBootstrap) {
31+ b := newMCPBootstrap(deps)
32+ h := mcp.NewRPCHandlerWithCredentialResolverAndToolAccess(nil, deps.CredentialResolver, mcp.ToolAccess(deps.Config.MCPToolAccess))
33+ h.SetToolBootstrap(b.ForTool)
34+ h.SetBootstrapDiagnostic(b.Diagnostic)
35+ h.SetServiceClient(b.ServiceClient)
36+ h.SetRuntimeContext(mcpRuntimeContext(deps))
37+ setMCPMaintenanceProviders(h.SetMaintenanceProviders, deps)
38+ return h, b
39+}
40+func (b *mcpBootstrap) ServiceClient() (*servicectl.RPCClient, error) {
41+ return (servicectl.Manager{Source: b.deps.Source, RuntimeDir: b.deps.Config.Service.RuntimeDir}).Client()
42+}
43+func (b *mcpBootstrap) Close() {
44+ b.mu.Lock()
45+ b.closed = true
46+ b.mu.Unlock()
47+ b.active.Wait()
48+ b.mu.Lock()
49+ defer b.mu.Unlock()
50+ if b.store != nil {
51+ _ = b.store.Close()
52+ b.store = nil
53+ }
54+}
55+func (b *mcpBootstrap) Readiness(ctx context.Context) mcp.Readiness {
56+ if ctx.Err() != nil {
57+ return mcp.Readiness{Ready: false, Code: "operation_cancelled", Message: "readiness observation was cancelled"}
58+ }
59+ if !b.mu.TryLock() {
60+ return mcp.Readiness{Ready: true, Code: "bootstrap_busy", Message: "transport is available; a requested tool bootstrap is in progress"}
61+ }
62+ defer b.mu.Unlock()
63+ if b.closed {
64+ return mcp.Readiness{Ready: false, Code: "service_unavailable", Message: "MCP transport is closing"}
65+ }
66+ if b.diagnostic.ErrorClass != "" {
67+ return mcp.Readiness{Ready: true, Code: b.diagnostic.ErrorClass, Message: "transport and daemon observation remain available; requested cache tools are degraded"}
68+ }
69+ return mcp.Readiness{Ready: true, Code: "transport_ready", Message: "transport is available; cache/provider readiness is evaluated only for a requested tool"}
70+}
71+ 
72+func mcpToolRequiresLive(name string) bool {
73+ if name == "sync_live" {
74+ return true
75+ }
76+ cap, ok := capability.LookupByMCPName(name)
77+ return ok && cap.Category == capability.CategoryWrite && cap.ID != "feedback_status" && cap.ID != "prepare_feedback"
78+}
79+ 
80+func (b *mcpBootstrap) ForTool(ctx context.Context, name string, _ json.RawMessage) (*mcp.RPCHandler, error) {
81+ ctx, cancel := context.WithTimeout(ctx, observability.ReadBudget)
82+ defer cancel()
83+ if err := ctx.Err(); err != nil {
84+ return nil, err
85+ }
86+ if !b.mu.TryLock() {
87+ return nil, servicectl.RPCDomainError{Code: "cache_bootstrap_busy", Message: "requested cache tool bootstrap is busy; retry the tool"}
88+ }
89+ defer b.mu.Unlock()
90+ if b.closed {
91+ return nil, servicectl.RPCDomainError{Code: "service_unavailable", Message: "MCP transport is closing"}
92+ }
93+ deps := b.deps
94+ deps.GitCode.Live = false
95+ deps.GitCode.Token = ""
96+ deps.GitCode.token = ""
97+ liveRequired := mcpToolRequiresLive(name)
98+ liveReadiness := name == "feedback_status" && !deps.GitCode.Offline
99+ if liveRequired || liveReadiness {
100+ if deps.GitCode.Offline {
101+ return nil, servicectl.RPCDomainError{Code: "live_disabled", Message: "requested live operation is unavailable in explicit offline mode"}
102+ }
103+ eff, err := config.LoadEffective(deps.Source, config.Overrides{})
104+ if err != nil {
105+ return nil, servicectl.RPCDomainError{Code: "configuration_error", Message: "effective configuration cannot be read for the requested live operation"}
106+ }
107+ // Per-request resolver avoids sharing mutable credential results between
108+ // concurrent explicit auth_status and provider operations.
109+ credential := auth.NewCredentialResolver(deps.Source).Resolve(ctx, eff)
110+ if err := ctx.Err(); err != nil {
111+ return nil, err
112+ }
113+ if liveRequired && (!credential.Present || strings.TrimSpace(credential.Token) == "") {
114+ return nil, servicectl.RPCDomainError{Code: "credential_unavailable", Message: "requested live operation needs a configured GitCode credential"}
115+ }
116+ deps.GitCode.Live = credential.Present && strings.TrimSpace(credential.Token) != ""
117+ deps.GitCode.Token = credential.Token
118+ deps.GitCode.token = credential.Token
119+ }
120+ if b.store == nil {
121+ if err := ensureParentDir(deps.Config.CachePath); err != nil {
122+ return nil, b.fail(err)
123+ }
124+ store, err := cache.NewSQLiteStore(ctx, deps.Config.CachePath)
125+ if err != nil {
126+ return nil, b.fail(err)
127+ }
128+ b.store = store
129+ b.diagnostic = mcp.StartupDiagnostic{}
130+ }
131+ if err := ctx.Err(); err != nil {
132+ return nil, err
133+ }
134+ svc, err := resolveService(b.store, deps)
135+ if err != nil {
136+ return nil, b.fail(err)
137+ }
138+ h := mcp.NewRPCHandlerWithCredentialResolverAndToolAccess(svc, deps.CredentialResolver, mcp.ToolAccess(deps.Config.MCPToolAccess))
139+ h.SetRAGStatusProvider(newMCPRAGStatusProvider(b.store, deps))
140+ h.SetRAGSearchProvider(newMCPRAGSearchProvider(b.store, deps))
141+ setMCPRepositoryDocsProviders(h.SetRepositoryDocsProviders, b.store, deps)
142+ b.active.Add(1)
143+ var release sync.Once
144+ h.SetToolRelease(func() { release.Do(b.active.Done) })
145+ return h, nil
146+}
147+ 
148+func (b *mcpBootstrap) Diagnostic() mcp.StartupDiagnostic {
149+ if !b.mu.TryLock() {
150+ return mcp.StartupDiagnostic{}
151+ }
152+ defer b.mu.Unlock()
153+ return b.diagnostic
154+}
155+func (b *mcpBootstrap) fail(err error) error {
156+ b.diagnostic = mcp.StartupDiagnosticFromError(err)
157+ return mcp.BootstrapError{Diagnostic: b.diagnostic}
158+}
@@ -0,0 +1,391 @@
1+package main
2+ 
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "io"
9+ "net"
10+ "net/http"
11+ "net/http/httptest"
12+ "os"
13+ "path/filepath"
14+ "reflect"
15+ "strings"
16+ "sync/atomic"
17+ "testing"
18+ "time"
19+ 
20+ "gitcode-mcp/internal/auth"
21+ "gitcode-mcp/internal/config"
22+ "gitcode-mcp/internal/mcp"
23+ "gitcode-mcp/internal/observability"
24+ "gitcode-mcp/internal/servicectl"
25+ "gitcode-mcp/internal/testnet"
26+)
27+ 
28+type observationCredentialSpy struct{ calls atomic.Int64 }
29+ 
30+func (p *observationCredentialSpy) Resolve(context.Context, config.EffectiveConfig) (config.SecretString, config.CredentialStatus, error) {
31+ p.calls.Add(1)
32+ return config.SecretString{}, config.CredentialStatus{}, nil
33+}
34+func (p *observationCredentialSpy) Status(context.Context, config.EffectiveConfig) config.CredentialStatus {
35+ p.calls.Add(1)
36+ return config.CredentialStatus{}
37+}
38+ 
39+func bootstrapObservationDaemon(t *testing.T) (string, *observability.Reader) {
40+ t.Helper()
41+ c := observability.New(observability.Config{Directory: t.TempDir(), ManagedOutput: true})
42+ if !c.Close(context.Background(), true) {
43+ t.Fatal("fixture collector did not close")
44+ }
45+ at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
46+ r := &observability.Reader{Collector: c, Metrics: observability.NewMetrics(c.BootID(), at), Now: func() time.Time { return at }}
47+ listener, err := net.Listen("tcp", "127.0.0.1:0")
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ ctx, cancel := context.WithCancel(context.Background())
52+ done := make(chan error, 1)
53+ go func() { done <- (servicectl.RPCServer{Observability: r}).Serve(ctx, listener) }()
54+ t.Cleanup(func() {
55+ cancel()
56+ _ = listener.Close()
57+ select {
58+ case <-done:
59+ case <-time.After(time.Second):
60+ t.Error("daemon fixture did not unwind")
61+ }
62+ })
63+ return listener.Addr().String(), r
64+}
65+ 
66+const observationDiscoveryInput = `{"jsonrpc":"2.0","id":1,"method":"initialize"}` + "\n" +
67+ `{"jsonrpc":"2.0","id":2,"method":"tools/list"}` + "\n" +
68+ `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"service_observability","arguments":{}}}` + "\n" +
69+ `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"service_metrics","arguments":{}}}` + "\n" +
70+ `{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"service_events","arguments":{}}}` + "\n"
71+ 
72+func TestMCPBootstrapObservationEntrypointFaultsNeverTouchCacheOrCredentials(t *testing.T) {
73+ address, _ := bootstrapObservationDaemon(t)
74+ for _, fault := range []string{"missing", "parent_failure", "unreadable", "future_schema", "migration_required"} {
75+ for _, credentialPresent := range []bool{false, true} {
76+ t.Run(fault+map[bool]string{false: "-missing-credential", true: "-credential-present"}[credentialPresent], func(t *testing.T) {
77+ root := t.TempDir()
78+ path := filepath.Join(root, "cache.db")
79+ switch fault {
80+ case "missing":
81+ path = filepath.Join(root, "uncreated", "cache.db")
82+ case "parent_failure":
83+ path = filepath.Join(root, "not-a-directory", "cache.db")
84+ if os.WriteFile(filepath.Dir(path), []byte("fixture"), 0600) != nil {
85+ t.Fatal("parent fixture failed")
86+ }
87+ case "unreadable":
88+ if os.WriteFile(path, []byte("not sqlite"), 0600) != nil {
89+ t.Fatal("unreadable fixture failed")
90+ }
91+ case "future_schema":
92+ writeSchemaVersion(t, path, 99)
93+ case "migration_required":
94+ writeSchemaVersion(t, path, 1)
95+ }
96+ before, readErr := os.ReadFile(path)
97+ src := newTestSource(t)
98+ src.env["GITCODE_MCP_SERVICE_NETWORK"] = "tcp"
99+ src.env["GITCODE_MCP_SERVICE_ADDRESS"] = address
100+ if credentialPresent {
101+ src.env["GITCODE_MCP_TEST_KEYCHAIN_TOKEN"] = "synthetic-private-credential"
102+ }
103+ spy := &observationCredentialSpy{}
104+ old := mcpRoute
105+ defer func() { mcpRoute = old }()
106+ mcpRoute = func(ctx context.Context, in io.Reader, out, errOut io.Writer, deps StartupDeps) int {
107+ deps.CredentialResolver = auth.NewCredentialResolverWithProvider(spy)
108+ return runMCPStdio(ctx, in, out, errOut, deps)
109+ }
110+ var out, errOut bytes.Buffer
111+ if run([]string{"--mcp", "--cache-path", path}, strings.NewReader(observationDiscoveryInput), &out, &errOut, src) != 0 || errOut.Len() != 0 {
112+ t.Fatal("observation entrypoint failed on unrelated bootstrap fault")
113+ }
114+ lines := nonEmptyLines(out.String())
115+ if len(lines) != 5 {
116+ t.Fatal("observation entrypoint lost responses")
117+ }
118+ for _, line := range lines {
119+ var response struct {
120+ Error json.RawMessage `json:"error"`
121+ }
122+ if json.Unmarshal([]byte(line), &response) != nil || len(response.Error) != 0 {
123+ t.Fatal("observation read failed due to cache/credential fault")
124+ }
125+ }
126+ for _, name := range []string{"service_observability", "service_metrics", "service_events"} {
127+ if !entrypointMCPToolNames(t, lines[1])[name] {
128+ t.Fatal("read observation discovery missing")
129+ }
130+ }
131+ cfg := config.Default()
132+ cfg.CachePath = path
133+ deps := buildStartupDeps(cfg, "", false)
134+ deps.Source = src
135+ deps.CredentialResolver = auth.NewCredentialResolverWithProvider(spy)
136+ assertBootstrapHTTPDiscoveryAndObservations(t, deps, nil)
137+ after, afterErr := os.ReadFile(path)
138+ if !bytes.Equal(before, after) || (readErr == nil) != (afterErr == nil) || spy.calls.Load() != 0 {
139+ t.Fatal("discovery/read touched cache or resolved credentials")
140+ }
141+ if fault == "missing" {
142+ if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) {
143+ t.Fatal("observation created cache parent")
144+ }
145+ }
146+ if strings.Contains(out.String()+errOut.String(), path) || strings.Contains(out.String()+errOut.String(), "synthetic-private") {
147+ t.Fatal("observation leaked private startup authority")
148+ }
149+ })
150+ }
151+ }
152+}
153+ 
154+func assertBootstrapHTTPDiscoveryAndObservations(t *testing.T, deps StartupDeps, prepare func(*mcpBootstrap)) {
155+ t.Helper()
156+ h, bootstrap := newMCPHTTPHandler(deps)
157+ defer bootstrap.Close()
158+ if prepare != nil {
159+ prepare(bootstrap)
160+ }
161+ initialStore := bootstrap.store
162+ transport := mcp.NewHTTPSSETransport(h, mcp.ServerConfig{SessionID: func() string { return "bootstrap-observation" }, ReadinessProbe: bootstrap.Readiness})
163+ server := httptest.NewServer(transport.Handler())
164+ defer server.Close()
165+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
166+ defer cancel()
167+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/sse", nil)
168+ stream, err := server.Client().Do(req)
169+ if err != nil {
170+ t.Fatal("HTTP bootstrap fixture stream unavailable")
171+ }
172+ defer stream.Body.Close()
173+ scanner := bufio.NewScanner(stream.Body)
174+ scanner.Buffer(make([]byte, 4096), observability.MaxMetricsBytes+8192)
175+ readData := func() string {
176+ for scanner.Scan() {
177+ if strings.HasPrefix(scanner.Text(), "data: ") {
178+ return strings.TrimPrefix(scanner.Text(), "data: ")
179+ }
180+ }
181+ t.Fatal("bounded HTTP observation stream lost a response")
182+ return ""
183+ }
184+ endpoint := readData()
185+ if endpoint != "/message?session_id=bootstrap-observation" {
186+ t.Fatal("fixture endpoint malformed")
187+ }
188+ for _, raw := range nonEmptyLines(observationDiscoveryInput) {
189+ req, _ := http.NewRequestWithContext(ctx, http.MethodPost, server.URL+endpoint, strings.NewReader(raw))
190+ req.Header.Set("Content-Type", "application/json")
191+ resp, err := server.Client().Do(req)
192+ if err != nil {
193+ t.Fatal("HTTP bootstrap fixture request failed")
194+ }
195+ _, _ = io.Copy(io.Discard, resp.Body)
196+ _ = resp.Body.Close()
197+ if resp.StatusCode != http.StatusAccepted {
198+ t.Fatal("HTTP bootstrap fixture transport rejected valid request")
199+ }
200+ data := readData()
201+ var result struct {
202+ Error json.RawMessage `json:"error"`
203+ }
204+ if json.Unmarshal([]byte(data), &result) != nil || len(result.Error) != 0 || strings.Contains(data, "synthetic-private") || strings.Contains(data, deps.Config.CachePath) {
205+ t.Fatal("HTTP discovery/observation failed on unrelated bootstrap fault or leaked authority")
206+ }
207+ }
208+ if bootstrap.store != initialStore {
209+ t.Fatal("HTTP discovery/observation changed SQLite bootstrap state")
210+ }
211+}
212+ 
213+func TestMCPBootstrapProviderConstructionFailureKeepsObservationAvailable(t *testing.T) {
214+ address, _ := bootstrapObservationDaemon(t)
215+ src := newTestSource(t)
216+ src.env["GITCODE_MCP_SERVICE_NETWORK"] = "tcp"
217+ src.env["GITCODE_MCP_SERVICE_ADDRESS"] = address
218+ src.env[config.EnvToken] = "synthetic-private-credential"
219+ depsForPath := func(path string) StartupDeps {
220+ cfg := config.Default()
221+ cfg.CachePath = path
222+ deps := buildStartupDeps(cfg, "", false)
223+ deps.Source = src
224+ deps.CredentialResolver = auth.NewCredentialResolver(src)
225+ // Invalid adapter configuration injected after config validation fails
226+ // construction without provider, DNS, or OS credential-store access.
227+ deps.GitCode.BaseURL = "%synthetic-private-provider"
228+ return deps
229+ }
230+ call := `{"jsonrpc":"2.0","id":0,"method":"tools/call","params":{"name":"create_issue","arguments":{"repo_id":"fixture-a","title":"Public fixture","body":"fixture","write_mode":"live","idempotency_key":"bootstrap-provider-failure"}}}` + "\n"
231+ var out, errOut bytes.Buffer
232+ if runMCPStdio(context.Background(), strings.NewReader(call+observationDiscoveryInput), &out, &errOut, depsForPath(filepath.Join(t.TempDir(), "cache.db"))) != 0 || errOut.Len() != 0 {
233+ t.Fatal("provider fault disabled stdio transport")
234+ }
235+ lines := nonEmptyLines(out.String())
236+ if len(lines) != 6 {
237+ t.Fatal("provider fault lost stdio responses")
238+ }
239+ for i, line := range lines {
240+ var response struct {
241+ Error json.RawMessage `json:"error"`
242+ }
243+ if json.Unmarshal([]byte(line), &response) != nil || (len(response.Error) != 0) != (i == 0) || strings.Contains(line, "synthetic-private") {
244+ t.Fatal("provider fault leaked configuration or disabled observation")
245+ }
246+ }
247+ assertBootstrapHTTPDiscoveryAndObservations(t, depsForPath(filepath.Join(t.TempDir(), "cache.db")), func(b *mcpBootstrap) {
248+ if _, err := b.ForTool(context.Background(), "create_issue", nil); err == nil || b.Diagnostic().ErrorClass == "" {
249+ t.Fatal("provider construction fixture did not fail safely")
250+ }
251+ })
252+}
253+ 
254+func TestMCPBootstrapLiveWriteMissingCredentialAndOfflineFailClosed(t *testing.T) {
255+ for _, offline := range []bool{false, true} {
256+ src := newTestSource(t)
257+ cfgPath := filepath.Join(src.configDir, "fixture.yaml")
258+ src.env[config.EnvMCPConfigPath] = cfgPath
259+ src.files[cfgPath] = []byte("credential:\n store: env\n")
260+ path := filepath.Join(t.TempDir(), "uncreated", "cache.db")
261+ input := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_issue","arguments":{"repo_id":"fixture-a","title":"Public fixture","body":"fixture","write_mode":"live","idempotency_key":"negative-bootstrap-write"}}}` + "\n"
262+ args := []string{"--mcp", "--cache-path", path}
263+ if offline {
264+ args = append(args, "--offline")
265+ }
266+ var out, errOut bytes.Buffer
267+ if run(args, strings.NewReader(input), &out, &errOut, src) != 0 || errOut.Len() != 0 {
268+ t.Fatal("negative live fixture entrypoint failed")
269+ }
270+ var response struct {
271+ Error struct {
272+ Data struct {
273+ Code string `json:"code"`
274+ } `json:"data"`
275+ } `json:"error"`
276+ }
277+ if json.Unmarshal(out.Bytes(), &response) != nil {
278+ t.Fatal("negative live fixture response malformed")
279+ }
280+ want := "credential_unavailable"
281+ if offline {
282+ want = "live_disabled"
283+ }
284+ if response.Error.Data.Code != want {
285+ t.Fatal("live request fell back to fixture or lost fail-closed reason")
286+ }
287+ if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) {
288+ t.Fatal("rejected live request opened or created cache")
289+ }
290+ }
291+}
292+ 
293+func TestMCPBootstrapHTTPReadinessAndDiscoveryDoNotInitialize(t *testing.T) {
294+ src := newTestSource(t)
295+ address, _ := bootstrapObservationDaemon(t)
296+ src.env["GITCODE_MCP_SERVICE_NETWORK"] = "tcp"
297+ src.env["GITCODE_MCP_SERVICE_ADDRESS"] = address
298+ cfg := config.Default()
299+ cfg.CachePath = filepath.Join(t.TempDir(), "uncreated", "cache.db")
300+ deps := buildStartupDeps(cfg, "", false)
301+ deps.Source = src
302+ spy := &observationCredentialSpy{}
303+ deps.CredentialResolver = auth.NewCredentialResolverWithProvider(spy)
304+ h, b := newMCPHTTPHandler(deps)
305+ defer b.Close()
306+ transport := mcp.NewHTTPSSETransport(h, mcp.ServerConfig{ReadinessProbe: b.Readiness})
307+ server := httptest.NewServer(transport.Handler())
308+ defer server.Close()
309+ for _, url := range []string{"/health", "/ready"} {
310+ resp, err := server.Client().Get(server.URL + url)
311+ if err != nil {
312+ t.Fatal(err)
313+ }
314+ _, _ = io.Copy(io.Discard, resp.Body)
315+ _ = resp.Body.Close()
316+ if resp.StatusCode != http.StatusOK {
317+ t.Fatal("transport metadata unavailable before cache bootstrap")
318+ }
319+ }
320+ if b.store != nil || spy.calls.Load() != 0 {
321+ t.Fatal("HTTP readiness opened cache or resolved credentials")
322+ }
323+ if _, err := os.Stat(filepath.Dir(cfg.CachePath)); !os.IsNotExist(err) {
324+ t.Fatal("HTTP startup created cache parent")
325+ }
326+ // The real initialized store remains unowned until an explicit tool call.
327+ b.mu.Lock()
328+ ready := b.Readiness(context.Background())
329+ b.mu.Unlock()
330+ if !ready.Ready || ready.Code != "bootstrap_busy" {
331+ t.Fatal("cache bootstrap contention blocked transport readiness")
332+ }
333+}
334+ 
335+func TestMCPBootstrapLaterLiveWriteStillConfirmedAuditedAndIdempotent(t *testing.T) {
336+ api := testnet.NewGitCodeAPIServer(t)
337+ defer api.Close()
338+ path := filepath.Join(t.TempDir(), "cache.db")
339+ addRepoForStartupTest(t, path, api.BaseURL())
340+ src := newTestSource(t)
341+ src.env["GITCODE_MCP_TEST_KEYCHAIN_TOKEN"] = "test-token"
342+ src.env[config.EnvAPIURL] = api.BaseURL()
343+ input := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + "\n"
344+ call := `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"create_issue","arguments":{"repo_id":"fixture-a","title":"Offline compatibility fixture","body":"public-safe fixture","write_mode":"live","idempotency_key":"observation-bootstrap-write"}}}` + "\n"
345+ var out, errOut bytes.Buffer
346+ if run([]string{"--mcp", "--cache-path", path}, strings.NewReader(input+call+call), &out, &errOut, src) != 0 || errOut.Len() != 0 {
347+ t.Fatal("later authorized live tool failed")
348+ }
349+ lines := nonEmptyLines(out.String())
350+ if len(lines) != 3 {
351+ t.Fatal("missing audited write responses")
352+ }
353+ for _, line := range lines {
354+ var resp struct {
355+ Error json.RawMessage `json:"error"`
356+ }
357+ if json.Unmarshal([]byte(line), &resp) != nil || len(resp.Error) != 0 {
358+ t.Fatal("live write lost confirmed provider behavior")
359+ }
360+ }
361+ if api.Counts().CreateIssue != 1 || api.Counts().UnexpectedRequests != 0 {
362+ t.Fatal("live operation fell back to fixture or repeated unsafe POST")
363+ }
364+ assertStartupCreateConfirmation(t, path, "observation-bootstrap-write")
365+ if strings.Contains(out.String()+errOut.String(), "test-token") {
366+ t.Fatal("live credential leaked")
367+ }
368+}
369+ 
370+func TestMCPBootstrapClosedAndBusyAreBounded(t *testing.T) {
371+ src := newTestSource(t)
372+ cfg := config.Default()
373+ cfg.CachePath = filepath.Join(t.TempDir(), "uncreated", "cache.db")
374+ deps := buildStartupDeps(cfg, "", false)
375+ deps.Source = src
376+ b := newMCPBootstrap(deps)
377+ b.mu.Lock()
378+ start := time.Now()
379+ _, err := b.ForTool(context.Background(), "cache_status", nil)
380+ b.mu.Unlock()
381+ if err == nil || time.Since(start) > time.Second {
382+ t.Fatal("bootstrap waited on occupied state")
383+ }
384+ b.Close()
385+ if _, err := b.ForTool(context.Background(), "cache_status", nil); err == nil {
386+ t.Fatal("closed bootstrap reopened cache")
387+ }
388+ if !reflect.DeepEqual(b.Diagnostic(), mcp.StartupDiagnostic{}) {
389+ t.Fatal("busy/closed bootstrap fabricated cache diagnosis")
390+ }
391+}
@@ -91,7 +91,8 @@ flowchart TD
91| Repository documentation RAG | `internal/repositorydocs/`, metadata tables in `internal/cache/` | Resolves committed corpus policy, reads exact local Git objects, chunks in memory, publishes immutable revision sets/vectors, and hydrates verified citations. | Git is the only document-byte authority; aliases canonicalize before writes; no fetch and no persistent source/chunk text. |91| Repository documentation RAG | `internal/repositorydocs/`, metadata tables in `internal/cache/` | Resolves committed corpus policy, reads exact local Git objects, chunks in memory, publishes immutable revision sets/vectors, and hydrates verified citations. | Git is the only document-byte authority; aliases canonicalize before writes; no fetch and no persistent source/chunk text. |
92| Maintenance identity resolution | `internal/servicectl/maintenance.go`, `maintenance_conflicts.go`, Admin controls | Canonicalizes aliases, preserves lossless conflict candidates, renders a generation-fenced selection plan, then atomically promotes one repository authority or one complete physical clone-path cohort behind a cache-writer admission fence. | Distinct repositories never merge across a clone; public surfaces receive paired hashes/fingerprints only; cache paths, repository paths, and config snapshots remain daemon-private. |92| Maintenance identity resolution | `internal/servicectl/maintenance.go`, `maintenance_conflicts.go`, Admin controls | Canonicalizes aliases, preserves lossless conflict candidates, renders a generation-fenced selection plan, then atomically promotes one repository authority or one complete physical clone-path cohort behind a cache-writer admission fence. | Distinct repositories never merge across a clone; public surfaces receive paired hashes/fingerprints only; cache paths, repository paths, and config snapshots remain daemon-private. |
93| Maintenance coordinator | `internal/servicectl/` | Owns the per-user managed-cache registry, identity validation, head/tail scheduling, job coalescing, and sanitized lifecycle status. | Raw cache paths stay daemon-local; scheduling keys include durable cache UUID and repository. |93| Maintenance coordinator | `internal/servicectl/` | Owns the per-user managed-cache registry, identity validation, head/tail scheduling, job coalescing, and sanitized lifecycle status. | Raw cache paths stay daemon-local; scheduling keys include durable cache UUID and repository. |
94-| Bounded observation foundations | `internal/observability/` | Keeps catalog-only events and volatile fixed-cardinality metric samples separate from execution authority. | Recording never waits for disk, network, a probe or a telemetry reader; loss and unavailable probes remain explicit. Full transport/UI rollout is a separate cohort. |94+| Bounded observation foundations | `internal/observability/` | Keeps catalog-only events and volatile fixed-cardinality metric samples separate from execution authority. | Recording never waits for disk, network, a probe or a telemetry reader; loss and unavailable probes remain explicit. Dedicated UI rollout is a separate cohort. |
95+| Shared observation reader | `internal/observability/reader.go`, Admin/IPC/CLI/MCP transports | Publishes bounded `observability.v1` summary, metrics, and events from retained authority; validates whole-query cursors and projects safe-copy context. | No broad Admin snapshot, cache bootstrap, credentials, provider probes, sampler start, or hidden execution action. [Transport contract](daemon-observation.md). |
95| Indexer | `internal/index/` | Parses markdown, headings, stable ids, links, anchors, chunks, and freshness state. | Indexed chunks carry provenance and citations needed by CLI/MCP reads and snapshots. |96| Indexer | `internal/index/` | Parses markdown, headings, stable ids, links, anchors, chunks, and freshness state. | Indexed chunks carry provenance and citations needed by CLI/MCP reads and snapshots. |
96| GitCode provider boundary | `internal/gitcode/` | Defines provider interfaces, fixture behavior, live HTTP adapter, API models, route matrix, pagination, and write confirmation. | Live API shape decisions stay behind this adapter. Fixture mode remains deterministic and network-free. |97| GitCode provider boundary | `internal/gitcode/` | Defines provider interfaces, fixture behavior, live HTTP adapter, API models, route matrix, pagination, and write confirmation. | Live API shape decisions stay behind this adapter. Fixture mode remains deterministic and network-free. |
97| Route schema matrix | `internal/gitcode/route_schema_matrix.go` | Declares supported, deferred, and unsupported route families by product area. | Unsupported or deferred capabilities return explicit diagnostics instead of empty success. |98| Route schema matrix | `internal/gitcode/route_schema_matrix.go` | Declares supported, deferred, and unsupported route families by product area. | Unsupported or deferred capabilities return explicit diagnostics instead of empty success. |
Adocs/daemon-observation.md+176-0文件内容审核中,请稍后刷新重试
@@ -93,7 +93,7 @@ gitcode-mcp mcp serve --transport http-sse --bind 127.0.0.1:9021
93| Endpoint | Method | Description |93| Endpoint | Method | Description |
94|---|---|---|94|---|---|---|
95| `/health` | GET | Returns 200 if the server process is alive |95| `/health` | GET | Returns 200 if the server process is alive |
96-| `/ready` | GET | Returns 200 if the cache is readable and at least one repository is configured |96+| `/ready` | GET | Reports MCP transport/diagnostic readiness without opening the cache or resolving credentials |
97| `/sse` | GET | SSE endpoint for server-to-client events |97| `/sse` | GET | SSE endpoint for server-to-client events |
98| `/message` | POST | JSON-RPC request endpoint |98| `/message` | POST | JSON-RPC request endpoint |
99 99 
@@ -117,10 +117,15 @@ Expected readiness codes:
117 117 
118| Code | Meaning |118| Code | Meaning |
119|---|---|119|---|---|
120-| (empty) | Ready |120+| `transport_ready` | Transport ready; cache/provider readiness is evaluated only by a requested dependent tool |
121-| `cache_unreadable` | Cache database cannot be opened or read |121+| `bootstrap_busy` | Transport ready while another requested tool initializes its dependencies |
122-| `repo_unavailable` | No repositories configured |122+| A cache/bootstrap diagnostic | Transport and daemon observations remain ready, but dependent tools are degraded |
123-| `locked_writer` | Writer lock contention |123+| `service_unavailable` | Transport is closing |
124+| `operation_cancelled` | Readiness request was cancelled |
125+ 
126+`ready: true` does not prove cache or live-write readiness. Discovery and daemon
127+observations remain available with an unusable cache. See
128+[Daemon observation](daemon-observation.md) for bounds and typed handoffs.
124 129 
125### Client configuration (generic HTTP/SSE)130### Client configuration (generic HTTP/SSE)
126 131 
@@ -207,7 +212,13 @@ Zed stdio example for a repo-local cache:
207}212}
208```213```
209 214 
210-When credentials resolve, MCP startup selects the live provider by default for live lifecycle tools. Use `--offline` or `--fixture` only for deterministic fixture sessions. `doctor` reports the active `tool_access` and provider mode so agents can explain why write tools are or are not available.215+MCP startup and discovery do not resolve credentials or initialize the cache.
216+An explicitly requested live lifecycle tool resolves credentials lazily and
217+selects the live provider; missing credentials never fall back to fixture writes.
218+Use `--offline` or `--fixture` only for deterministic fixture sessions. `doctor`
219+reports `tool_access`, provider mode, and an explicit cache bootstrap diagnostic
220+when needed. Feedback submission readiness is evaluated by `feedback_status`,
221+not as a hidden discovery-side credential lookup.
211 222 
212### MCP tools exposed223### MCP tools exposed
213 224 
@@ -215,6 +226,9 @@ Tools are available in both transport modes. Read-only mode lists the cache/read
215 226 
216| Tool | Description |227| Tool | Description |
217|---|---|228|---|---|
229+| `service_observability` | Bounded daemon summary separating execution and observation health; no cache/provider bootstrap |
230+| `service_metrics` | Fixed-catalog sampled metrics and up to 60 minute buckets; unavailable values stay null |
231+| `service_events` | Bounded retained events with finite outcome/failure filters, opaque joins, and whole-query cursor fencing |
218| `search_sources` | Hybrid source search by default; lexical results always participate, semantic chunks are grouped into source citations, and `mode=full_text` disables the embedding branch |232| `search_sources` | Hybrid source search by default; lexical results always participate, semantic chunks are grouped into source citations, and `mode=full_text` disables the embedding branch |
219| `get_source` | Get a cached source record by stable id |233| `get_source` | Get a cached source record by stable id |
220| `list_sources` | List cached sources with kind/status/limit/offset |234| `list_sources` | List cached sources with kind/status/limit/offset |
@@ -199,6 +199,21 @@ preserves original events without replaying job/provider/commit throughput.
199Registered observability polling emits no self-events; unsupported/user-prefix199Registered observability polling emits no self-events; unsupported/user-prefix
200paths remain observable ordinary requests. Synthetic control, cookie, body,200paths remain observable ordinary requests. Synthetic control, cookie, body,
201header, request-id and URL sentinels must be absent from retained evidence.201header, request-id and URL sentinels must be absent from retained evidence.
202+Shared observation qualification uses actual private TCP IPC plus CLI, stdio,
203+normal/minimal HTTP/SSE, and authenticated Admin routes. Tests reject malformed
204+arguments before connection/retained reads, hold actual snapshot locks, cancel
205+silent established peers, fence whole-filter cursors, and compare serialized
206+DTOs including full sampled 60-minute metric windows above 64 KiB. IPC pipeline
207+framing and shutdown-handler joining are explicit invariants. Entrypoint fault
208+fixtures cover missing/damaged/old/future/cache-parent state and actual adapter
209+construction failure: discovery and observation do not create/migrate the cache
210+or resolve credentials. Later explicit live writes use offline HTTP fixtures to
211+prove provider confirmation, audit, and one POST on idempotent replay; absent
212+credentials/offline requests fail closed rather than selecting fixture writes.
213+Safe-copy fixtures accept only returned catalog/incident evidence, reject raw
214+free-form reasons, and preserve omitted unknown authority references. The
215+dedicated UI and integrated rollout remain separately qualified cohorts; see
216+[Daemon observation](daemon-observation.md).
202Maintenance identity tests also enroll canonical and alias repository ids217Maintenance identity tests also enroll canonical and alias repository ids
203concurrently, migrate compatible and conflicting version-1 registry entries,218concurrently, migrate compatible and conflicting version-1 registry entries,
204project legacy registration/job references to the canonical identity, preserve219project legacy registration/job references to the canonical identity, preserve
Ainternal/adminhttp/observability.go+167-0文件内容审核中,请稍后刷新重试
Ainternal/adminhttp/observability_test.go+252-0文件内容审核中,请稍后刷新重试
@@ -17,6 +17,7 @@ import (
17 "time"17 "time"
18 18 
19 "gitcode-mcp/internal/feedback"19 "gitcode-mcp/internal/feedback"
20+ "gitcode-mcp/internal/observability"
20)21)
21 22 
22const observationAPIVersion = "1"23const observationAPIVersion = "1"
@@ -90,6 +91,7 @@ type AttentionItem struct {
90}91}
91 92 
92type CacheObservation struct {93type CacheObservation struct {
94+ ObservationRefs *observability.EntityRefs `json:"observation_refs,omitempty"`
93 CacheRef string `json:"cache_ref"`95 CacheRef string `json:"cache_ref"`
94 PathFingerprint string `json:"path_fingerprint,omitempty"`96 PathFingerprint string `json:"path_fingerprint,omitempty"`
95 StorageMode string `json:"storage_mode,omitempty"`97 StorageMode string `json:"storage_mode,omitempty"`
@@ -129,20 +131,22 @@ type SchemaRecoveryObservation struct {
129}131}
130 132 
131type RepositoryObservation struct {133type RepositoryObservation struct {
132- RepoID string `json:"repo_id"`134+ ObservationRefs *observability.EntityRefs `json:"observation_refs,omitempty"`
133- DisplayName string `json:"display_name,omitempty"`135+ RepoID string `json:"repo_id"`
134- Aliases []string `json:"aliases,omitempty"`136+ DisplayName string `json:"display_name,omitempty"`
135- Scopes []string `json:"scopes,omitempty"`137+ Aliases []string `json:"aliases,omitempty"`
136- BindingState string `json:"binding_state"`138+ Scopes []string `json:"scopes,omitempty"`
137- Counts CollectionCounts `json:"counts"`139+ BindingState string `json:"binding_state"`
138- Coverage CoverageObservation `json:"coverage"`140+ Counts CollectionCounts `json:"counts"`
139- Execution ExecutionObservation `json:"execution"`141+ Coverage CoverageObservation `json:"coverage"`
140- Collections []CollectionObservation `json:"collections"`142+ Execution ExecutionObservation `json:"execution"`
141- RecentSync []SyncEventObservation `json:"recent_sync_events"`143+ Collections []CollectionObservation `json:"collections"`
142- Documentation *RepositoryDocumentationObservation `json:"documentation,omitempty"`144+ RecentSync []SyncEventObservation `json:"recent_sync_events"`
145+ Documentation *RepositoryDocumentationObservation `json:"documentation,omitempty"`
143}146}
144 147 
145type RepositoryDocumentationObservation struct {148type RepositoryDocumentationObservation struct {
149+ ObservationRefs *observability.EntityRefs `json:"observation_refs,omitempty"`
146 State string `json:"state"`150 State string `json:"state"`
147 Registered bool `json:"registered"`151 Registered bool `json:"registered"`
148 RegistrationID string `json:"registration_id,omitempty"`152 RegistrationID string `json:"registration_id,omitempty"`
@@ -191,13 +195,14 @@ type RepositoryDocumentationRetention struct {
191}195}
192 196 
193type RepositoryDocumentationSource struct {197type RepositoryDocumentationSource struct {
194- SourceID string `json:"source_registration_id"`198+ ObservationRefs *observability.EntityRefs `json:"observation_refs,omitempty"`
195- SourceGeneration int64 `json:"source_registration_generation"`199+ SourceID string `json:"source_registration_id"`
196- State string `json:"state"`200+ SourceGeneration int64 `json:"source_registration_generation"`
197- GitStoreRef string `json:"git_store_ref,omitempty"`201+ State string `json:"state"`
198- WorktreeRef string `json:"worktree_ref,omitempty"`202+ GitStoreRef string `json:"git_store_ref,omitempty"`
199- CommitOID string `json:"commit_oid,omitempty"`203+ WorktreeRef string `json:"worktree_ref,omitempty"`
200- PolicyHash string `json:"policy_hash,omitempty"`204+ CommitOID string `json:"commit_oid,omitempty"`
205+ PolicyHash string `json:"policy_hash,omitempty"`
201}206}
202 207 
203type RepositoryDocumentationExclusionCount struct {208type RepositoryDocumentationExclusionCount struct {
@@ -287,6 +292,7 @@ type StageError struct {
287}292}
288 293 
289type JobObservation struct {294type JobObservation struct {
295+ ObservationRefs *observability.EntityRefs `json:"observation_refs,omitempty"`
290 ID string `json:"id"`296 ID string `json:"id"`
291 Type string `json:"type"`297 Type string `json:"type"`
292 CacheRef string `json:"cache_ref,omitempty"`298 CacheRef string `json:"cache_ref,omitempty"`
@@ -376,6 +382,7 @@ type ProgressObservation struct {
376}382}
377 383 
378type MaintenanceObservation struct {384type MaintenanceObservation struct {
385+ ObservationRefs *observability.EntityRefs `json:"observation_refs,omitempty"`
379 RegistrationID string `json:"registration_id"`386 RegistrationID string `json:"registration_id"`
380 CacheRef string `json:"cache_ref"`387 CacheRef string `json:"cache_ref"`
381 RepoID string `json:"repo_id"`388 RepoID string `json:"repo_id"`
@@ -132,7 +132,7 @@ func TestAdminCompletedRequestRealMuxPrivacyAndOutcomes(t *testing.T) {
132 {"GET", "/api/admin/v1/session", "session", "rejected", 401, false, false},132 {"GET", "/api/admin/v1/session", "session", "rejected", 401, false, false},
133 // These paths are not registered telemetry endpoints yet; they must133 // These paths are not registered telemetry endpoints yet; they must
134 // not be silently excluded based on the user's raw path.134 // not be silently excluded based on the user's raw path.
135- {"GET", "/api/admin/v1/observability/metrics", "unknown", "succeeded", 200, false, false},135+ {"GET", "/api/admin/v1/observability/not-registered", "unknown", "succeeded", 200, false, false},
136 {"GET", "/synthetic-private-spa?token=synthetic-private-query", "assets", "succeeded", 200, false, false},136 {"GET", "/synthetic-private-spa?token=synthetic-private-query", "assets", "succeeded", 200, false, false},
137 } {137 } {
138 r := httptest.NewRequest(tc.method, "http://127.0.0.1:8080"+tc.target, strings.NewReader("synthetic-private-body"))138 r := httptest.NewRequest(tc.method, "http://127.0.0.1:8080"+tc.target, strings.NewReader("synthetic-private-body"))
@@ -81,6 +81,7 @@ type Config struct {
81 ApplyRAGRepair RAGRepairProvider81 ApplyRAGRepair RAGRepairProvider
82 Metrics *observability.Metrics82 Metrics *observability.Metrics
83 Observation *observability.Collector83 Observation *observability.Collector
84+ Observability *observability.Reader
84}85}
85 86 
86type Controller struct {87type Controller struct {
@@ -145,11 +146,16 @@ func (c *Controller) Start(ctx context.Context) (Status, error) {
145 }146 }
146 }()147 }()
147 go func() { _ = c.server.Serve(ln) }()148 go func() { _ = c.server.Serve(ln) }()
149+ var pollers sync.WaitGroup
148 if c.cfg.Snapshot != nil {150 if c.cfg.Snapshot != nil {
149- go func() { defer close(pollDone); c.pollObservation(ctx) }()151+ pollers.Add(1)
150- } else {152+ go func() { defer pollers.Done(); c.pollObservation(ctx) }()
151- close(pollDone)
152 }153 }
154+ if c.cfg.Observability != nil {
155+ pollers.Add(1)
156+ go func() { defer pollers.Done(); c.pollObservability(ctx) }()
157+ }
158+ go func() { pollers.Wait(); close(pollDone) }()
153 return c.status, nil159 return c.status, nil
154}160}
155 161 
@@ -239,6 +245,9 @@ func (c *Controller) handler() http.Handler {
239 mux.HandleFunc("GET /api/admin/v1/diagnostics", c.getDiagnostics)245 mux.HandleFunc("GET /api/admin/v1/diagnostics", c.getDiagnostics)
240 mux.HandleFunc("GET /api/admin/v1/capabilities", c.getCapabilities)246 mux.HandleFunc("GET /api/admin/v1/capabilities", c.getCapabilities)
241 mux.HandleFunc("GET /api/admin/v1/events", c.getEvents)247 mux.HandleFunc("GET /api/admin/v1/events", c.getEvents)
248+ mux.HandleFunc("GET /api/admin/v1/observability", c.getObservability)
249+ mux.HandleFunc("GET /api/admin/v1/observability/metrics", c.getObservabilityMetrics)
250+ mux.HandleFunc("GET /api/admin/v1/observability/events", c.getObservabilityEvents)
242 mux.HandleFunc("/", c.serveAsset)251 mux.HandleFunc("/", c.serveAsset)
243 return c.observeRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {252 return c.observeRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
244 setSecurityHeaders(w)253 setSecurityHeaders(w)
@@ -2,6 +2,7 @@ package auth
2 2 
3import (3import (
4 "context"4 "context"
5+ "sync"
5 6 
6 "gitcode-mcp/internal/config"7 "gitcode-mcp/internal/config"
7)8)
@@ -19,6 +20,7 @@ type Result struct {
19}20}
20 21 
21type CredentialResolver struct {22type CredentialResolver struct {
23+ mu sync.Mutex
22 provider config.CredentialProvider24 provider config.CredentialProvider
23 result *Result25 result *Result
24}26}
@@ -34,11 +36,24 @@ func NewCredentialResolverWithProvider(provider config.CredentialProvider) *Cred
34}36}
35 37 
36func (r *CredentialResolver) Resolve(ctx context.Context, eff config.EffectiveConfig) Result {38func (r *CredentialResolver) Resolve(ctx context.Context, eff config.EffectiveConfig) Result {
39+ if ctx.Err() != nil {
40+ return cancelledResolution()
41+ }
42+ // MCP can now reach first-use credential diagnostics concurrently, without
43+ // an eager startup result. Serialize that first use without making a second
44+ // request wait behind a platform credential provider or cache transient state.
45+ if !r.mu.TryLock() {
46+ return Result{Source: "unavailable", ErrorClass: "credential-resolution-busy", Remediation: "Credential resolution is in progress; retry the explicit diagnostic or live operation."}
47+ }
48+ defer r.mu.Unlock()
37 if r.result != nil {49 if r.result != nil {
38 return *r.result50 return *r.result
39 }51 }
40 eff = normalizeEffectiveConfig(eff)52 eff = normalizeEffectiveConfig(eff)
41 secret, status, _ := r.provider.Resolve(ctx, eff)53 secret, status, _ := r.provider.Resolve(ctx, eff)
54+ if ctx.Err() != nil {
55+ return cancelledResolution()
56+ }
42 result := Result{57 result := Result{
43 Present: status.Present,58 Present: status.Present,
44 Token: secret.Value(),59 Token: secret.Value(),
@@ -51,6 +66,10 @@ func (r *CredentialResolver) Resolve(ctx context.Context, eff config.EffectiveCo
51 return result66 return result
52}67}
53 68 
69+func cancelledResolution() Result {
70+ return Result{Source: "unavailable", ErrorClass: "credential-resolution-cancelled", Remediation: "Credential resolution was cancelled; retry only if still needed."}
71+}
72+ 
54func (r *CredentialResolver) Status(ctx context.Context, eff config.EffectiveConfig) Result {73func (r *CredentialResolver) Status(ctx context.Context, eff config.EffectiveConfig) Result {
55 return r.Resolve(ctx, eff)74 return r.Resolve(ctx, eff)
56}75}
@@ -3,7 +3,10 @@ package auth
3import (3import (
4 "context"4 "context"
5 "strings"5 "strings"
6+ "sync"
7+ "sync/atomic"
6 "testing"8 "testing"
9+ "time"
7 10 
8 "gitcode-mcp/internal/config"11 "gitcode-mcp/internal/config"
9)12)
@@ -141,3 +144,82 @@ func TestCredentialResolverEnvOverKeyring(t *testing.T) {
141 t.Fatalf("expected token 'env-token', got %q", result.Token)144 t.Fatalf("expected token 'env-token', got %q", result.Token)
142 }145 }
143}146}
147+ 
148+type blockingCredentialFixture struct {
149+ calls atomic.Int64
150+ started chan struct{}
151+ release chan struct{}
152+}
153+ 
154+func (p *blockingCredentialFixture) Resolve(ctx context.Context, _ config.EffectiveConfig) (config.SecretString, config.CredentialStatus, error) {
155+ p.calls.Add(1)
156+ close(p.started)
157+ select {
158+ case <-p.release:
159+ return config.NewSecretString("synthetic-private-credential"), config.CredentialStatus{Source: "mock-keyring", Present: true}, nil
160+ case <-ctx.Done():
161+ return config.SecretString{}, config.CredentialStatus{}, ctx.Err()
162+ }
163+}
164+func (p *blockingCredentialFixture) Status(ctx context.Context, eff config.EffectiveConfig) config.CredentialStatus {
165+ _, status, _ := p.Resolve(ctx, eff)
166+ return status
167+}
168+ 
169+func TestCredentialResolverConcurrentLazyFirstUseDoesNotWaitOrCacheBusy(t *testing.T) {
170+ p := &blockingCredentialFixture{started: make(chan struct{}), release: make(chan struct{})}
171+ r := NewCredentialResolverWithProvider(p)
172+ ctx, cancel := context.WithCancel(context.Background())
173+ defer cancel()
174+ done := make(chan Result, 1)
175+ go func() { done <- r.Resolve(ctx, config.EffectiveConfig{}) }()
176+ select {
177+ case <-p.started:
178+ case <-time.After(time.Second):
179+ t.Fatal("credential fixture did not start")
180+ }
181+ var wg sync.WaitGroup
182+ var wrong atomic.Bool
183+ start := time.Now()
184+ for i := 0; i < 32; i++ {
185+ wg.Add(1)
186+ go func() {
187+ defer wg.Done()
188+ result := r.Status(context.Background(), config.EffectiveConfig{})
189+ if result.Present || result.Token != "" || result.ErrorClass != "credential-resolution-busy" {
190+ wrong.Store(true)
191+ }
192+ }()
193+ }
194+ wg.Wait()
195+ if wrong.Load() || p.calls.Load() != 1 || time.Since(start) > time.Second {
196+ t.Error("concurrent first use waited, amplified credential calls or cached busy state")
197+ }
198+ close(p.release)
199+ select {
200+ case result := <-done:
201+ if !result.Present {
202+ t.Fatal("owning credential resolution failed")
203+ }
204+ case <-time.After(time.Second):
205+ t.Fatal("credential owner did not join")
206+ }
207+ if !r.Status(context.Background(), config.EffectiveConfig{}).Present || p.calls.Load() != 1 {
208+ t.Fatal("completed credential result was not reused")
209+ }
210+}
211+ 
212+func TestCredentialResolverCancelledReadDoesNotResolveOrCacheAbsence(t *testing.T) {
213+ src := newTestSource()
214+ src.env[config.EnvToken] = "synthetic-private-credential"
215+ r := NewCredentialResolverWithProvider(config.EnvCredentialProvider{Source: src})
216+ ctx, cancel := context.WithCancel(context.Background())
217+ cancel()
218+ result := r.Resolve(ctx, config.EffectiveConfig{})
219+ if result.Present || result.ErrorClass != "credential-resolution-cancelled" || r.result != nil {
220+ t.Fatal("cancelled resolution cached absence or ignored context")
221+ }
222+ if !r.Resolve(context.Background(), config.EffectiveConfig{}).Present {
223+ t.Fatal("cancelled first use prevented later explicit resolution")
224+ }
225+}
@@ -383,6 +383,9 @@ func executeWithFactoryAndDeps(args []string, stdout io.Writer, stderr io.Writer
383}383}
384 384 
385func executeWithFactoryAndDepsContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, factory serviceFactory, deps localCommandDeps) int {385func executeWithFactoryAndDepsContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, factory serviceFactory, deps localCommandDeps) int {
386+ if IsObservationCommand(args) {
387+ return executeObservationCommand(ctx, args[1:], stdout, stderr, deps)
388+ }
386 if deps.Source == nil {389 if deps.Source == nil {
387 deps.Source = config.OSSource{}390 deps.Source = config.OSSource{}
388 }391 }
@@ -6082,6 +6085,9 @@ func printCommandHelp(command string, w io.Writer) {
6082 fmt.Fprintln(w, " stop report how to stop the installed service")6085 fmt.Fprintln(w, " stop report how to stop the installed service")
6083 fmt.Fprintln(w, " status inspect runtime state")6086 fmt.Fprintln(w, " status inspect runtime state")
6084 fmt.Fprintln(w, " doctor inspect service health")6087 fmt.Fprintln(w, " doctor inspect service health")
6088+ fmt.Fprintln(w, " observe read bounded execution and observation health")
6089+ fmt.Fprintln(w, " metrics read fixed-catalog current metrics and sampled history")
6090+ fmt.Fprintln(w, " events read bounded filtered observation events")
6085 fmt.Fprintln(w, " maintenance inspect daemon-managed cache and RAG lifecycle state")6091 fmt.Fprintln(w, " maintenance inspect daemon-managed cache and RAG lifecycle state")
6086 fmt.Fprintln(w, " reconcile request an immediate maintenance reconciliation")6092 fmt.Fprintln(w, " reconcile request an immediate maintenance reconciliation")
6087 fmt.Fprintln(w, " run run the coordinator foreground process")6093 fmt.Fprintln(w, " run run the coordinator foreground process")
@@ -0,0 +1,162 @@
1+package cli
2+ 
3+import (
4+ "context"
5+ "flag"
6+ "fmt"
7+ "io"
8+ "strings"
9+ 
10+ "gitcode-mcp/internal/config"
11+ "gitcode-mcp/internal/observability"
12+ "gitcode-mcp/internal/service"
13+ "gitcode-mcp/internal/servicectl"
14+)
15+ 
16+func IsObservationCommand(args []string) bool {
17+ if len(args) < 2 || args[0] != "service" {
18+ return false
19+ }
20+ return args[1] == "observe" || args[1] == "metrics" || args[1] == "events"
21+}
22+ 
23+func executeObservationCommand(ctx context.Context, args []string, stdout, stderr io.Writer, deps localCommandDeps) int {
24+ sub := args[0]
25+ format := "text"
26+ help := false
27+ events := observability.EventsRequest{Limit: 50}
28+ metrics := observability.MetricsRequest{HistoryMinutes: 60}
29+ flags := flag.NewFlagSet("service "+sub, flag.ContinueOnError)
30+ flags.SetOutput(io.Discard)
31+ flags.StringVar(&format, "format", "text", "text, markdown, or json")
32+ flags.BoolVar(&help, "help", false, "show read-only command help")
33+ flags.BoolVar(&help, "h", false, "show read-only command help")
34+ if sub == "metrics" {
35+ flags.StringVar(&metrics.Name, "name", "", "fixed metric name")
36+ flags.IntVar(&metrics.HistoryMinutes, "history-minutes", 60, "sampled history minutes (1..60)")
37+ }
38+ if sub == "events" {
39+ for _, f := range []struct {
40+ name string
41+ value *string
42+ }{
43+ {"stream", &events.Stream}, {"severity", &events.Severity}, {"component", &events.Component}, {"phase", &events.Phase}, {"collection", &events.Collection}, {"outcome", &events.Outcome}, {"failure-class", &events.FailureClass},
44+ {"job-ref", &events.JobRef}, {"registration-ref", &events.RegistrationRef}, {"cache-ref", &events.CacheRef}, {"repo-ref", &events.RepoRef}, {"correlation-ref", &events.CorrelationRef}, {"stage-ref", &events.StageRef}, {"source-ref", &events.SourceRef}, {"previous-registration-ref", &events.PreviousRegistrationRef},
45+ {"since", &events.Since}, {"until", &events.Until}, {"cursor", &events.Cursor},
46+ } {
47+ flags.StringVar(f.value, f.name, "", "bounded event selector")
48+ }
49+ flags.Var(observationCodeFlag{&events.Code}, "code", "fixed event code")
50+ flags.IntVar(&events.Limit, "limit", 50, "result count (1..200)")
51+ }
52+ invalid := service.ErrInvalidQuery{Field: "observation", Message: "unknown, repeated, or invalid observation flag or positional argument"}
53+ // Standard flag parsing accepts repeats; this read boundary deliberately
54+ // refuses them before config or IPC access, including repeated booleans.
55+ seen := map[string]bool{}
56+ for i := 1; i < len(args); i++ {
57+ a := args[i]
58+ if !strings.HasPrefix(a, "-") || a == "--" {
59+ return writeError(stderr, format, invalid)
60+ }
61+ name := strings.TrimLeft(strings.SplitN(a, "=", 2)[0], "-")
62+ f := flags.Lookup(name)
63+ if f == nil || seen[name] {
64+ return writeError(stderr, format, invalid)
65+ }
66+ seen[name] = true
67+ if name != "h" && name != "help" && !strings.Contains(a, "=") {
68+ i++
69+ if i >= len(args) {
70+ return writeError(stderr, format, invalid)
71+ }
72+ }
73+ }
74+ if err := flags.Parse(args[1:]); err != nil || len(flags.Args()) > 0 {
75+ return writeError(stderr, format, invalid)
76+ }
77+ if format != "text" && format != "markdown" && format != "json" {
78+ return writeError(stderr, "text", invalid)
79+ }
80+ if help {
81+ fmt.Fprintf(stdout, "Usage: gitcode-mcp service %s [--format text|markdown|json]\n", sub)
82+ flags.SetOutput(stdout)
83+ flags.PrintDefaults()
84+ fmt.Fprintln(stdout, "Read-only: one 2s budget; no cache/provider bootstrap, credential lookup, or daemon startup.")
85+ return 0
86+ }
87+ var params any
88+ var err error
89+ if sub == "events" {
90+ events, err = events.Validate()
91+ params = events
92+ }
93+ if sub == "metrics" {
94+ metrics, err = metrics.Validate()
95+ params = metrics
96+ }
97+ if err != nil {
98+ return writeError(stderr, format, err)
99+ }
100+ if deps.Source == nil {
101+ deps.Source = config.OSSource{}
102+ }
103+ effective, err := config.LoadEffective(deps.Source, config.Overrides{})
104+ if err != nil {
105+ return writeError(stderr, format, service.ErrInvalidQuery{Field: "config", Message: "effective configuration is invalid; inspect config diagnostics"})
106+ }
107+ manager := servicectl.Manager{Source: deps.Source, RuntimeDir: effective.Config.Service.RuntimeDir}
108+ client, err := manager.Client()
109+ if err != nil {
110+ return writeError(stderr, format, servicectl.RPCDomainError{Code: "service_unavailable", Message: "local daemon is unavailable; explicitly start it before retrying"})
111+ }
112+ switch sub {
113+ case "observe":
114+ var result observability.Summary
115+ err = client.Call(ctx, "Observability.Summary", nil, &result)
116+ if err == nil {
117+ return render(stdout, format, result, func(w io.Writer, s observability.Summary) {
118+ fmt.Fprintf(w, "protocol: %s\nboot: %s\nexecution: %s (%s)\nobservation: %s (%s)\n", s.Protocol, s.Collector.BootID, s.ExecutionHealth.State, s.ExecutionHealth.Coverage, s.ObservationHealth.State, s.ObservationHealth.Reason)
119+ for _, p := range s.Resources {
120+ fmt.Fprintf(w, "%s: %s\n", p.Name, observationMetricValue(p))
121+ }
122+ })
123+ }
124+ case "metrics":
125+ var result observability.MetricsSnapshot
126+ err = client.Call(ctx, "Observability.Metrics", params, &result)
127+ if err == nil {
128+ return render(stdout, format, result, func(w io.Writer, s observability.MetricsSnapshot) {
129+ fmt.Fprintf(w, "boot: %s\nstate: %s\nsampled_at: %s\n", s.BootID, s.State, s.SampledAt)
130+ for _, p := range s.Series {
131+ fmt.Fprintf(w, "%s: %s (history: %d minutes)\n", p.Name, observationMetricValue(p.MetricPoint), len(p.History))
132+ }
133+ })
134+ }
135+ case "events":
136+ var result observability.Page
137+ err = client.Call(ctx, "Observability.Events", params, &result)
138+ if err == nil {
139+ return render(stdout, format, result, func(w io.Writer, p observability.Page) {
140+ fmt.Fprintf(w, "coverage: %s\nhas_more: %t\ncursor: %s\n", p.Coverage.State, p.HasMore, p.Cursor)
141+ for _, e := range p.Events {
142+ fmt.Fprintf(w, "%d %s %s %s %s\n", e.Sequence, e.Code, e.Phase, e.Outcome, e.Message)
143+ }
144+ })
145+ }
146+ }
147+ return writeError(stderr, format, err)
148+}
149+ 
150+type observationCodeFlag struct{ value *observability.Code }
151+ 
152+func (f observationCodeFlag) String() string { return string(*f.value) }
153+func (f observationCodeFlag) Set(value string) error {
154+ *f.value = observability.Code(value)
155+ return nil
156+}
157+func observationMetricValue(p observability.MetricPoint) string {
158+ if p.Value == nil {
159+ return "unavailable (" + p.Reason + ")"
160+ }
161+ return fmt.Sprintf("%g", *p.Value)
162+}
@@ -0,0 +1,144 @@
1+package cli
2+ 
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "net"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+ "time"
13+ 
14+ "gitcode-mcp/internal/observability"
15+ "gitcode-mcp/internal/servicectl"
16+)
17+ 
18+type observationCLISource struct {
19+ *repoInitLocalSource
20+ reads int
21+}
22+ 
23+func (s *observationCLISource) ReadFile(path string) ([]byte, error) {
24+ s.reads++
25+ return s.repoInitLocalSource.ReadFile(path)
26+}
27+ 
28+func TestObservationCLIRejectsBeforeConfigOrBootstrap(t *testing.T) {
29+ root := t.TempDir()
30+ src := &observationCLISource{repoInitLocalSource: &repoInitLocalSource{env: map[string]string{}, cwd: root, homeDir: root, configDir: root, cacheDir: root}}
31+ for _, args := range [][]string{
32+ {"service", "observe", "--live"}, {"service", "observe", "synthetic-private-coordinate"}, {"service", "observe", "--repo", "synthetic-private-coordinate"},
33+ {"service", "observe", "--cache-path", "synthetic-private-path"}, {"service", "observe", "--format", "json", "--format", "json"},
34+ {"service", "metrics", "--history-minutes", "0"}, {"service", "metrics", "--history-minutes", "61"}, {"service", "metrics", "--name", "synthetic-private-name"},
35+ {"service", "events", "--limit", "0"}, {"service", "events", "--limit", "201"}, {"service", "events", "--limit", "50", "--limit", "20"},
36+ {"service", "events", "--source-ref", "synthetic-private-path"}, {"service", "events", "--outcome", "succeeded", "--failure-class", "timeout"},
37+ {"service", "events", "--since", "2026-01-01T00:00:00Z"},
38+ {"service", "events", "--stream", "stderr", "--code", "job_terminal"},
39+ } {
40+ var out, errOut bytes.Buffer
41+ code := executeWithFactoryAndDepsContext(context.Background(), args, &out, &errOut, func(context.Context, string) (queryService, func() error, error) {
42+ t.Fatal("observation initialized a cache/provider service")
43+ return nil, nil, nil
44+ }, localCommandDeps{Source: src})
45+ if code == 0 || out.Len() != 0 || strings.Contains(errOut.String(), "synthetic-private") {
46+ t.Fatal("invalid observation CLI accepted or leaked input")
47+ }
48+ }
49+ if src.reads != 0 {
50+ t.Fatal("invalid observation CLI read configuration")
51+ }
52+ entries, err := os.ReadDir(root)
53+ if err != nil || len(entries) != 0 {
54+ t.Fatal("invalid observation CLI created local state")
55+ }
56+}
57+ 
58+func TestObservationCLIMissingDaemonAndHelpDoNotCreateCache(t *testing.T) {
59+ root := t.TempDir()
60+ src := &observationCLISource{repoInitLocalSource: &repoInitLocalSource{env: map[string]string{"GITCODE_MCP_SERVICE_NETWORK": "mem", "GITCODE_MCP_SERVICE_ADDRESS": "synthetic-private-absent"}, cwd: root, homeDir: filepath.Join(root, "h"), configDir: filepath.Join(root, "f"), cacheDir: filepath.Join(root, "c")}}
61+ for _, sub := range []string{"observe", "metrics", "events"} {
62+ var out, errOut bytes.Buffer
63+ code := ExecuteWithSource([]string{"service", sub, "--format", "json"}, &out, &errOut, src)
64+ if code == 0 || out.Len() != 0 || !strings.Contains(errOut.String(), "service_unavailable") || strings.Contains(errOut.String(), "synthetic-private") {
65+ t.Fatal("missing daemon error missing or unsafe")
66+ }
67+ out.Reset()
68+ errOut.Reset()
69+ before := src.reads
70+ code = ExecuteWithSource([]string{"service", sub, "--help"}, &out, &errOut, src)
71+ if code != 0 || errOut.Len() != 0 || !strings.Contains(out.String(), "Read-only") || src.reads != before {
72+ t.Fatal("observation help performed bootstrap")
73+ }
74+ }
75+ entries, err := os.ReadDir(root)
76+ if err != nil || len(entries) != 0 {
77+ t.Fatal("observation read created cache, runtime, or service definition")
78+ }
79+}
80+ 
81+func TestObservationCLIActualIPCParityWithoutCacheFactory(t *testing.T) {
82+ at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
83+ c := observability.New(observability.Config{Directory: t.TempDir(), ManagedOutput: true})
84+ if !c.Close(context.Background(), true) {
85+ t.Fatal("collector fixture did not close")
86+ }
87+ m := observability.NewMetrics(c.BootID(), at)
88+ if !m.SampleAt(at, map[string]observability.ProbeValue{"runtime_heap": observability.Available(42)}) {
89+ t.Fatal("sample fixture unavailable")
90+ }
91+ r := &observability.Reader{Collector: c, Metrics: m, Now: func() time.Time { return at }}
92+ listener, err := net.Listen("tcp", "127.0.0.1:0")
93+ if err != nil {
94+ t.Fatal("IPC fixture unavailable")
95+ }
96+ ctx, cancel := context.WithCancel(context.Background())
97+ done := make(chan error, 1)
98+ go func() { done <- (servicectl.RPCServer{Observability: r}).Serve(ctx, listener) }()
99+ defer func() {
100+ cancel()
101+ select {
102+ case <-done:
103+ case <-time.After(time.Second):
104+ t.Error("CLI IPC fixture did not join")
105+ }
106+ }()
107+ root := t.TempDir()
108+ src := &observationCLISource{repoInitLocalSource: &repoInitLocalSource{env: map[string]string{"GITCODE_MCP_SERVICE_NETWORK": "tcp", "GITCODE_MCP_SERVICE_ADDRESS": listener.Addr().String()}, cwd: root, homeDir: root, configDir: root, cacheDir: root}}
109+ wantSummary, err := r.Summary(ctx)
110+ if err != nil {
111+ t.Fatal("summary fixture unavailable")
112+ }
113+ wantMetrics, err := r.MetricSnapshot(ctx, observability.MetricsRequest{Name: "runtime_heap", HistoryMinutes: 3})
114+ if err != nil {
115+ t.Fatal("metrics fixture unavailable")
116+ }
117+ wantEvents, err := r.Events(ctx, observability.EventsRequest{Limit: 50})
118+ if err != nil {
119+ t.Fatal("events fixture unavailable")
120+ }
121+ for _, test := range []struct {
122+ args []string
123+ want any
124+ }{
125+ {[]string{"service", "observe", "--format", "json"}, wantSummary},
126+ {[]string{"service", "metrics", "--name", "runtime_heap", "--history-minutes", "3", "--format", "json"}, wantMetrics},
127+ {[]string{"service", "events", "--format", "json"}, wantEvents},
128+ } {
129+ var out, errOut bytes.Buffer
130+ code := executeWithFactoryAndDepsContext(ctx, test.args, &out, &errOut, func(context.Context, string) (queryService, func() error, error) {
131+ t.Fatal("CLI observation opened cache/provider")
132+ return nil, nil, nil
133+ }, localCommandDeps{Source: src})
134+ want, _ := json.Marshal(test.want)
135+ var compact bytes.Buffer
136+ if code != 0 || errOut.Len() != 0 || json.Compact(&compact, out.Bytes()) != nil || compact.String() != string(want) {
137+ t.Fatal("CLI observation diverged from shared reader")
138+ }
139+ }
140+ entries, err := os.ReadDir(root)
141+ if err != nil || len(entries) != 0 {
142+ t.Fatal("CLI observation created local state")
143+ }
144+}
@@ -20,6 +20,7 @@ import (
20 "gitcode-mcp/internal/diagnostics"20 "gitcode-mcp/internal/diagnostics"
21 "gitcode-mcp/internal/feedback"21 "gitcode-mcp/internal/feedback"
22 "gitcode-mcp/internal/gitcode"22 "gitcode-mcp/internal/gitcode"
23+ "gitcode-mcp/internal/observability"
23 "gitcode-mcp/internal/rag"24 "gitcode-mcp/internal/rag"
24 "gitcode-mcp/internal/repositorydocs"25 "gitcode-mcp/internal/repositorydocs"
25 "gitcode-mcp/internal/service"26 "gitcode-mcp/internal/service"
@@ -98,6 +99,10 @@ func (s *Server) feedbackReadiness(ctx context.Context) (feedback.Readiness, err
98}99}
99 100 
100type RPCHandler struct {101type RPCHandler struct {
102+ toolRelease func()
103+ bootstrapDiagnostic func() StartupDiagnostic
104+ toolBootstrap ToolBootstrap
105+ serviceClient func() (*servicectl.RPCClient, error)
101 svc serviceInterface106 svc serviceInterface
102 startupDiagnostic StartupDiagnostic107 startupDiagnostic StartupDiagnostic
103 minimal bool108 minimal bool
@@ -114,6 +119,8 @@ type RPCHandler struct {
114}119}
115 120 
116type Server struct {121type Server struct {
122+ bootstrapDiagnostic func() StartupDiagnostic
123+ toolBootstrap ToolBootstrap
117 reader io.Reader124 reader io.Reader
118 writer io.Writer125 writer io.Writer
119 stderr io.Writer126 stderr io.Writer
@@ -248,6 +255,10 @@ func (h *RPCHandler) Handle(ctx context.Context, req request) (*response, bool)
248 }255 }
249 var buf bytes.Buffer256 var buf bytes.Buffer
250 server := &Server{writer: &buf, stderr: io.Discard, handler: h, svc: h.svc, startupDiagnostic: h.startupDiagnostic, minimal: h.minimal, credentialResolver: h.credentialResolver, toolAccess: normalizeToolAccess(h.toolAccess), ragStatus: h.ragStatus, ragSearch: h.ragSearch, repositoryDocsPolicy: h.repositoryDocsPolicy, repositoryDocsStatus: h.repositoryDocsStatus, repositoryDocsSearch: h.repositoryDocsSearch, maintenancePlan: h.maintenancePlan, maintenanceApply: h.maintenanceApply, runtimeContext: h.runtimeContext}257 server := &Server{writer: &buf, stderr: io.Discard, handler: h, svc: h.svc, startupDiagnostic: h.startupDiagnostic, minimal: h.minimal, credentialResolver: h.credentialResolver, toolAccess: normalizeToolAccess(h.toolAccess), ragStatus: h.ragStatus, ragSearch: h.ragSearch, repositoryDocsPolicy: h.repositoryDocsPolicy, repositoryDocsStatus: h.repositoryDocsStatus, repositoryDocsSearch: h.repositoryDocsSearch, maintenancePlan: h.maintenancePlan, maintenanceApply: h.maintenanceApply, runtimeContext: h.runtimeContext}
258+ server.serviceClient, server.toolBootstrap = h.serviceClient, h.toolBootstrap
259+ if h.bootstrapDiagnostic != nil {
260+ server.startupDiagnostic = h.bootstrapDiagnostic()
261+ }
251 server.handle(ctx, req, req.ID == nil)262 server.handle(ctx, req, req.ID == nil)
252 line := bytes.TrimSpace(buf.Bytes())263 line := bytes.TrimSpace(buf.Bytes())
253 if len(line) == 0 {264 if len(line) == 0 {
@@ -281,34 +292,37 @@ type rpcError struct {
281}292}
282 293 
283type errorData struct {294type errorData struct {
284- Code string `json:"code"`295+ Observation *observability.QueryError `json:"observation,omitempty"`
285- Message string `json:"message"`296+ Code string `json:"code"`
286- FailureClass string `json:"failure_class,omitempty"`297+ Message string `json:"message"`
287- Operation string `json:"operation,omitempty"`298+ FailureClass string `json:"failure_class,omitempty"`
288- RepoID string `json:"repo_id,omitempty"`299+ Operation string `json:"operation,omitempty"`
289- StartedAt string `json:"started_at,omitempty"`300+ RepoID string `json:"repo_id,omitempty"`
290- PID int `json:"pid,omitempty"`301+ StartedAt string `json:"started_at,omitempty"`
291- CacheRef string `json:"cache_ref,omitempty"`302+ PID int `json:"pid,omitempty"`
292- AccessMode string `json:"access_mode,omitempty"`303+ CacheRef string `json:"cache_ref,omitempty"`
293- Remediation string `json:"remediation,omitempty"`304+ AccessMode string `json:"access_mode,omitempty"`
294- IdempotencyKey string `json:"idempotency_key,omitempty"`305+ Remediation string `json:"remediation,omitempty"`
295- RemotePath string `json:"remote_path,omitempty"`306+ IdempotencyKey string `json:"idempotency_key,omitempty"`
296- WritePhase string `json:"write_phase,omitempty"`307+ RemotePath string `json:"remote_path,omitempty"`
297- MutationAttempted *bool `json:"mutation_attempted,omitempty"`308+ WritePhase string `json:"write_phase,omitempty"`
298- ProviderFailureClass string `json:"provider_failure_class,omitempty"`309+ MutationAttempted *bool `json:"mutation_attempted,omitempty"`
310+ ProviderFailureClass string `json:"provider_failure_class,omitempty"`
299}311}
300 312 
301type toolDefinition struct {313type toolDefinition struct {
302- Name string `json:"name"`314+ Annotations map[string]bool `json:"annotations,omitempty"`
303- Description string `json:"description"`315+ Name string `json:"name"`
304- InputSchema inputSchema `json:"inputSchema"`316+ Description string `json:"description"`
305- Meta map[string]any `json:"_meta,omitempty"`317+ InputSchema inputSchema `json:"inputSchema"`
318+ Meta map[string]any `json:"_meta,omitempty"`
306}319}
307 320 
308type inputSchema struct {321type inputSchema struct {
309- Type string `json:"type"`322+ AdditionalProperties *bool `json:"additionalProperties,omitempty"`
310- Properties map[string]schemaProp `json:"properties"`323+ Type string `json:"type"`
311- Required []string `json:"required,omitempty"`324+ Properties map[string]schemaProp `json:"properties"`
325+ Required []string `json:"required,omitempty"`
312}326}
313 327 
314type schemaProp struct {328type schemaProp struct {
@@ -902,6 +916,11 @@ func (s *Server) toolsList(ctx context.Context, req request) {
902 }916 }
903 definition := tool.definition917 definition := tool.definition
904 if name == "submit_feedback" {918 if name == "submit_feedback" {
919+ if s.toolBootstrap != nil && s.svc == nil {
920+ definition.Description += " Runtime readiness is not evaluated during discovery; call feedback_status explicitly."
921+ tools = append(tools, definition)
922+ continue
923+ }
905 readiness, err := s.feedbackReadiness(ctx)924 readiness, err := s.feedbackReadiness(ctx)
906 if err != nil {925 if err != nil {
907 readiness = feedback.Readiness{926 readiness = feedback.Readiness{
@@ -942,6 +961,12 @@ func (s *Server) toolsCall(ctx context.Context, req request) {
942 s.writeError(req.ID, -32602, "Invalid params", &errorData{Code: "invalid_params", Message: "name is required"})961 s.writeError(req.ID, -32602, "Invalid params", &errorData{Code: "invalid_params", Message: "name is required"})
943 return962 return
944 }963 }
964+ if _, observation := observationToolDefinition(params.Name); observation {
965+ if err := decodeObservationCall(*req.Params, &params); err != nil {
966+ s.writeObservationError(req.ID, err)
967+ return
968+ }
969+ }
945 970 
946 if s.toolDisabledByPolicy(params.Name) {971 if s.toolDisabledByPolicy(params.Name) {
947 s.writeToolDisabledByPolicy(req.ID, params.Name)972 s.writeToolDisabledByPolicy(req.ID, params.Name)
@@ -963,6 +988,29 @@ func (s *Server) toolsCall(ctx context.Context, req request) {
963 s.writeError(req.ID, -32601, "Method not found", &errorData{Code: "unknown_tool", Message: fmt.Sprintf("unknown tool %q", params.Name)})988 s.writeError(req.ID, -32601, "Method not found", &errorData{Code: "unknown_tool", Message: fmt.Sprintf("unknown tool %q", params.Name)})
964 return989 return
965 }990 }
991+ if s.toolBootstrap != nil && toolNeedsBootstrap(params.Name) {
992+ dependencies, err := s.toolBootstrap(ctx, params.Name, args)
993+ if err != nil {
994+ var startup BootstrapError
995+ if params.Name == "doctor" && errors.As(err, &startup) {
996+ s.startupDiagnostic = startup.Diagnostic
997+ tool.handler(ctx, req.ID, args)
998+ return
999+ }
1000+ s.writeOperationalError(req.ID, err, domainErrorContext{Operation: params.Name})
1001+ return
1002+ }
1003+ if dependencies != nil && dependencies.toolRelease != nil {
1004+ defer dependencies.toolRelease()
1005+ }
1006+ if dependencies == nil || dependencies.svc == nil {
1007+ s.writeOperationalError(req.ID, errors.New("tool dependency bootstrap unavailable"), domainErrorContext{Operation: params.Name})
1008+ return
1009+ }
1010+ s.svc = dependencies.svc
1011+ s.ragStatus, s.ragSearch = dependencies.ragStatus, dependencies.ragSearch
1012+ s.repositoryDocsPolicy, s.repositoryDocsStatus, s.repositoryDocsSearch = dependencies.repositoryDocsPolicy, dependencies.repositoryDocsStatus, dependencies.repositoryDocsSearch
1013+ }
966 tool.handler(ctx, req.ID, args)1014 tool.handler(ctx, req.ID, args)
967}1015}
968 1016 
@@ -984,6 +1032,9 @@ var preWriteToolListOrder = []string{
984 "diff_snapshot",1032 "diff_snapshot",
985 "repo_status",1033 "repo_status",
986 "service_status",1034 "service_status",
1035+ "service_observability",
1036+ "service_metrics",
1037+ "service_events",
987 "maintenance_status",1038 "maintenance_status",
988 "maintenance_plan",1039 "maintenance_plan",
989 "enable_cache_maintenance",1040 "enable_cache_maintenance",
@@ -1016,6 +1067,9 @@ func buildToolListOrder() []string {
1016}1067}
1017 1068 
1018func toolDefinitionByName(name string) toolDefinition {1069func toolDefinitionByName(name string) toolDefinition {
1070+ if definition, ok := observationToolDefinition(name); ok {
1071+ return definition
1072+ }
1019 for _, def := range toolDefs {1073 for _, def := range toolDefs {
1020 if def.Name == name {1074 if def.Name == name {
1021 return def1075 return def
@@ -1058,6 +1112,9 @@ func (s *Server) ragToolHandler(cap capability.Capability) toolHandler {
1058 1112 
1059func (s *Server) toolRegistry() toolRegistry {1113func (s *Server) toolRegistry() toolRegistry {
1060 registry := toolRegistry{}1114 registry := toolRegistry{}
1115+ registerTool(registry, "service_observability", s.callServiceObservability)
1116+ registerTool(registry, "service_metrics", s.callServiceMetrics)
1117+ registerTool(registry, "service_events", s.callServiceEvents)
1061 if s.minimal {1118 if s.minimal {
1062 registerTool(registry, "doctor", s.callDoctor)1119 registerTool(registry, "doctor", s.callDoctor)
1063 return registry1120 return registry
@@ -2595,8 +2652,13 @@ func classifyDomainError(err error, ctx domainErrorContext) *errorData {
2595 var schemaErr *cache.SchemaVersionError2652 var schemaErr *cache.SchemaVersionError
2596 var parentMissing service.ErrParentPRNotCached2653 var parentMissing service.ErrParentPRNotCached
2597 var rpcErr servicectl.RPCDomainError2654 var rpcErr servicectl.RPCDomainError
2655+ var bootstrapErr BootstrapError
2598 2656 
2599 switch {2657 switch {
2658+ case errors.As(err, &bootstrapErr):
2659+ data.Code = bootstrapErr.Diagnostic.ErrorClass
2660+ data.Message = bootstrapErr.Diagnostic.Message
2661+ data.Remediation = bootstrapErr.Diagnostic.Remediation
2600 case errors.As(err, &notFound) && notFound.Kind == "repository":2662 case errors.As(err, &notFound) && notFound.Kind == "repository":
2601 data.Code = "missing_repository_binding"2663 data.Code = "missing_repository_binding"
2602 data.Message = fmt.Sprintf("repository binding %q is not configured", firstNonEmpty(data.RepoID, notFound.ID))2664 data.Message = fmt.Sprintf("repository binding %q is not configured", firstNonEmpty(data.RepoID, notFound.ID))
@@ -2591,9 +2591,7 @@ func TestStartupDiagnosticInjection(t *testing.T) {
2591 if list.StartupDiagnostic == nil || list.StartupDiagnostic.ErrorClass == "" || list.StartupDiagnostic.Message == "" || list.StartupDiagnostic.Remediation == "" {2591 if list.StartupDiagnostic == nil || list.StartupDiagnostic.ErrorClass == "" || list.StartupDiagnostic.Message == "" || list.StartupDiagnostic.Remediation == "" {
2592 t.Fatalf("tools/list startup diagnostic=%+v", list.StartupDiagnostic)2592 t.Fatalf("tools/list startup diagnostic=%+v", list.StartupDiagnostic)
2593 }2593 }
2594- if len(list.Tools) != 1 || list.Tools[0].Name != "doctor" {2594+ assertMinimalObservationTools(t, list.Tools)
2595- t.Fatalf("minimal tools/list tools=%+v", list.Tools)
2596- }
2597 2595 
2598 doctorParams := json.RawMessage(`{"name":"doctor","arguments":{}}`)2596 doctorParams := json.RawMessage(`{"name":"doctor","arguments":{}}`)
2599 doctorID := json.RawMessage(`"doctor"`)2597 doctorID := json.RawMessage(`"doctor"`)
@@ -2659,9 +2657,7 @@ func TestStartupDiagnosticSchemaIncompatible(t *testing.T) {
2659 if list.StartupDiagnostic == nil || list.StartupDiagnostic.ErrorClass != "schema_incompatible" || list.StartupDiagnostic.Message == "" || list.StartupDiagnostic.Remediation == "" {2657 if list.StartupDiagnostic == nil || list.StartupDiagnostic.ErrorClass != "schema_incompatible" || list.StartupDiagnostic.Message == "" || list.StartupDiagnostic.Remediation == "" {
2660 t.Fatalf("tools/list startup diagnostic=%+v", list.StartupDiagnostic)2658 t.Fatalf("tools/list startup diagnostic=%+v", list.StartupDiagnostic)
2661 }2659 }
2662- if len(list.Tools) != 1 || list.Tools[0].Name != "doctor" {2660+ assertMinimalObservationTools(t, list.Tools)
2663- t.Fatalf("minimal tools/list tools=%+v", list.Tools)
2664- }
2665 2661 
2666 doctorParams := json.RawMessage(`{"name":"doctor","arguments":{}}`)2662 doctorParams := json.RawMessage(`{"name":"doctor","arguments":{}}`)
2667 doctorID := json.RawMessage(`"doctor"`)2663 doctorID := json.RawMessage(`"doctor"`)
@@ -2725,9 +2721,7 @@ func TestStartupDiagnosticCacheLockContention(t *testing.T) {
2725 if list.StartupDiagnostic == nil || list.StartupDiagnostic.ErrorClass != "cache_lock_contention" || list.StartupDiagnostic.Message == "" || list.StartupDiagnostic.Remediation == "" {2721 if list.StartupDiagnostic == nil || list.StartupDiagnostic.ErrorClass != "cache_lock_contention" || list.StartupDiagnostic.Message == "" || list.StartupDiagnostic.Remediation == "" {
2726 t.Fatalf("tools/list startup diagnostic=%+v", list.StartupDiagnostic)2722 t.Fatalf("tools/list startup diagnostic=%+v", list.StartupDiagnostic)
2727 }2723 }
2728- if len(list.Tools) != 1 || list.Tools[0].Name != "doctor" {2724+ assertMinimalObservationTools(t, list.Tools)
2729- t.Fatalf("minimal tools/list tools=%+v", list.Tools)
2730- }
2731 2725 
2732 doctorParams := json.RawMessage(`{"name":"doctor","arguments":{}}`)2726 doctorParams := json.RawMessage(`{"name":"doctor","arguments":{}}`)
2733 doctorID := json.RawMessage(`"doctor"`)2727 doctorID := json.RawMessage(`"doctor"`)
@@ -2797,9 +2791,7 @@ func TestStartupDiagnosticStartupFailure(t *testing.T) {
2797 if list.StartupDiagnostic == nil || list.StartupDiagnostic.ErrorClass != "startup-failure" || list.StartupDiagnostic.Message == "" || list.StartupDiagnostic.Remediation == "" {2791 if list.StartupDiagnostic == nil || list.StartupDiagnostic.ErrorClass != "startup-failure" || list.StartupDiagnostic.Message == "" || list.StartupDiagnostic.Remediation == "" {
2798 t.Fatalf("tools/list startup diagnostic=%+v", list.StartupDiagnostic)2792 t.Fatalf("tools/list startup diagnostic=%+v", list.StartupDiagnostic)
2799 }2793 }
2800- if len(list.Tools) != 1 || list.Tools[0].Name != "doctor" {2794+ assertMinimalObservationTools(t, list.Tools)
2801- t.Fatalf("minimal tools/list tools=%+v", list.Tools)
2802- }
2803 2795 
2804 doctorParams := json.RawMessage(`{"name":"doctor","arguments":{}}`)2796 doctorParams := json.RawMessage(`{"name":"doctor","arguments":{}}`)
2805 doctorID := json.RawMessage(`"doctor"`)2797 doctorID := json.RawMessage(`"doctor"`)
@@ -2872,9 +2864,7 @@ func TestStartupDiagnosticAllScenarios(t *testing.T) {
2872 if !strings.Contains(list.StartupDiagnostic.Remediation, tt.remediation) {2864 if !strings.Contains(list.StartupDiagnostic.Remediation, tt.remediation) {
2873 t.Fatalf("tools/list startup diagnostic remediation=%q does not contain %q", list.StartupDiagnostic.Remediation, tt.remediation)2865 t.Fatalf("tools/list startup diagnostic remediation=%q does not contain %q", list.StartupDiagnostic.Remediation, tt.remediation)
2874 }2866 }
2875- if len(list.Tools) != 1 || list.Tools[0].Name != "doctor" {2867+ assertMinimalObservationTools(t, list.Tools)
2876- t.Fatalf("minimal tools/list returned tools=%+v", list.Tools)
2877- }
2878 2868 
2879 doctorParams := json.RawMessage(`{"name":"doctor","arguments":{}}`)2869 doctorParams := json.RawMessage(`{"name":"doctor","arguments":{}}`)
2880 doctorID := json.RawMessage(`"doctor"`)2870 doctorID := json.RawMessage(`"doctor"`)
@@ -0,0 +1,207 @@
1+package mcp
2+ 
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "io"
9+ 
10+ "gitcode-mcp/internal/observability"
11+ "gitcode-mcp/internal/servicectl"
12+)
13+ 
14+func decodeObservationCall(raw []byte, dst *toolCallParams) error {
15+ invalid := observability.QueryError{Code: "invalid_query", Reason: "unsupported_request"}
16+ if len(raw) > observability.MaxReadRequestBytes+1024 {
17+ return invalid
18+ }
19+ d := json.NewDecoder(bytes.NewReader(raw))
20+ start, err := d.Token()
21+ if err != nil || start != json.Delim('{') {
22+ return invalid
23+ }
24+ seen := map[string]bool{}
25+ for d.More() {
26+ key, err := d.Token()
27+ name, ok := key.(string)
28+ if err != nil || !ok || seen[name] || (name != "name" && name != "arguments") {
29+ return invalid
30+ }
31+ seen[name] = true
32+ var value json.RawMessage
33+ if d.Decode(&value) != nil || bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
34+ return invalid
35+ }
36+ }
37+ if end, err := d.Token(); err != nil || end != json.Delim('}') {
38+ return invalid
39+ }
40+ if _, err := d.Token(); err != io.EOF {
41+ return invalid
42+ }
43+ if json.Unmarshal(raw, dst) != nil || dst.Name == "" {
44+ return invalid
45+ }
46+ return nil
47+}
48+ 
49+// ToolBootstrap supplies dependencies to one request only. Discovery and local
50+// observation never call it or contend with its cache/credential initialization.
51+type ToolBootstrap func(context.Context, string, json.RawMessage) (*RPCHandler, error)
52+ 
53+// The bootstrap's store lease lasts through the handler, not just construction.
54+func (h *RPCHandler) SetToolRelease(release func()) { h.toolRelease = release }
55+ 
56+type BootstrapError struct{ Diagnostic StartupDiagnostic }
57+ 
58+func (e BootstrapError) Error() string { return e.Diagnostic.Message }
59+func (h *RPCHandler) SetBootstrapDiagnostic(provider func() StartupDiagnostic) {
60+ h.bootstrapDiagnostic = provider
61+}
62+func (s *Server) SetBootstrapDiagnostic(provider func() StartupDiagnostic) {
63+ s.bootstrapDiagnostic = provider
64+ if s.handler != nil {
65+ s.handler.SetBootstrapDiagnostic(provider)
66+ }
67+}
68+ 
69+func (h *RPCHandler) SetToolBootstrap(bootstrap ToolBootstrap) { h.toolBootstrap = bootstrap }
70+func (s *Server) SetToolBootstrap(bootstrap ToolBootstrap) {
71+ s.toolBootstrap = bootstrap
72+ if s.handler != nil {
73+ s.handler.SetToolBootstrap(bootstrap)
74+ }
75+}
76+func (h *RPCHandler) SetServiceClient(provider func() (*servicectl.RPCClient, error)) {
77+ h.serviceClient = provider
78+}
79+func (s *Server) SetServiceClient(provider func() (*servicectl.RPCClient, error)) {
80+ s.serviceClient = provider
81+ if s.handler != nil {
82+ s.handler.SetServiceClient(provider)
83+ }
84+}
85+ 
86+func toolNeedsBootstrap(name string) bool {
87+ switch name {
88+ case "service_observability", "service_metrics", "service_events", "service_status", "maintenance_status", "maintenance_plan", "enable_cache_maintenance", "service_jobs", "service_job_status", "service_job_attach", "service_job_cancel", "repository_docs_sources", "repository_docs_plan", "repository_docs_index", "auth_status":
89+ return false
90+ }
91+ return true
92+}
93+ 
94+func observationToolDefinition(name string) (toolDefinition, bool) {
95+ props := map[string]schemaProp{}
96+ description := "Read a bounded daemon-owned summary with separate execution and observation health and nullable unsampled resources. Never starts the daemon, opens/migrates a cache, resolves credentials, or repairs work."
97+ switch name {
98+ case "service_observability":
99+ case "service_metrics":
100+ description = "Read the fixed-name bounded daemon metric snapshot and retained minute history. Unsampled values remain unavailable/null. No cache bootstrap, provider setup, sampling, or daemon start."
101+ props["name"] = schemaProp{Type: "string", Description: "Optional fixed metric name."}
102+ name := props["name"]
103+ for _, d := range observability.MetricCatalog() {
104+ name.Enum = append(name.Enum, d.Name)
105+ }
106+ props["name"] = name
107+ props["history_minutes"] = schemaProp{Type: "integer", Minimum: float64Ptr(1), Maximum: float64Ptr(60), Default: 60}
108+ case "service_events":
109+ description = "Read a bounded retained execution ledger page. Severity is not outcome; filter explicit outcome/failure_class and use opaque refs from listed resources/summary incidents. Cursor binds all selectors, boot generation and loss; on snapshot_required restart without cursor. No live sync, cache bootstrap, credential resolution, or daemon start."
110+ for _, key := range []string{"stream", "severity", "component", "code", "phase", "collection", "outcome", "failure_class", "job_ref", "registration_ref", "cache_ref", "repo_ref", "correlation_ref", "stage_ref", "source_ref", "previous_registration_ref", "since", "until", "cursor"} {
111+ props[key] = schemaProp{Type: "string", Description: "Opaque ref from already returned local entity/event/incident evidence; never a repository id, filesystem path, or private coordinate."}
112+ }
113+ for key, values := range observability.FilterValues() {
114+ props[key] = schemaProp{Type: "string", Description: "Finite event dimension; incompatible selector combinations are rejected. Severity is not outcome.", Enum: values}
115+ }
116+ for _, key := range []string{"since", "until"} {
117+ props[key] = schemaProp{Type: "string", Description: "Paired ordered RFC3339Nano UTC (Z) range, at most seven days. Omit both for retained evidence only."}
118+ }
119+ props["cursor"] = schemaProp{Type: "string", Description: "Opaque signed cursor from the preceding page; reuse all identical selectors. Refresh without it on snapshot_required."}
120+ props["limit"] = schemaProp{Type: "integer", Minimum: float64Ptr(1), Maximum: float64Ptr(200), Default: 50}
121+ default:
122+ return toolDefinition{}, false
123+ }
124+ strict := false
125+ return toolDefinition{Name: name, Description: description, Annotations: map[string]bool{"readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false}, InputSchema: inputSchema{Type: "object", Properties: props, AdditionalProperties: &strict}, Meta: map[string]any{"gitcode_mcp": map[string]any{"read_only": true, "protocol": observability.Protocol, "limits": observability.Limits()}}}, true
126+}
127+ 
128+func (s *Server) callServiceObservability(ctx context.Context, id *json.RawMessage, args json.RawMessage) {
129+ if err := observability.DecodeReadRequest(args, &struct{}{}); err != nil {
130+ s.writeObservationError(id, err)
131+ return
132+ }
133+ var result observability.Summary
134+ s.callObservation(ctx, id, "Observability.Summary", struct{}{}, &result)
135+}
136+func (s *Server) callServiceMetrics(ctx context.Context, id *json.RawMessage, args json.RawMessage) {
137+ params, err := observability.DecodeMetricsRequest(args)
138+ if err != nil {
139+ s.writeObservationError(id, err)
140+ return
141+ }
142+ var result observability.MetricsSnapshot
143+ s.callObservation(ctx, id, "Observability.Metrics", params, &result)
144+}
145+func (s *Server) callServiceEvents(ctx context.Context, id *json.RawMessage, args json.RawMessage) {
146+ params, err := observability.DecodeEventsRequest(args)
147+ if err != nil {
148+ s.writeObservationError(id, err)
149+ return
150+ }
151+ var result observability.Page
152+ s.callObservation(ctx, id, "Observability.Events", params, &result)
153+}
154+func (s *Server) callObservation(ctx context.Context, id *json.RawMessage, method string, params any, result any) {
155+ ctx, cancel := context.WithTimeout(ctx, observability.ReadBudget)
156+ defer cancel()
157+ client, err := s.localServiceClient()
158+ if err != nil {
159+ s.writeObservationError(id, servicectl.RPCDomainError{Code: "service_unavailable", Message: "local daemon is unavailable; explicitly start it before retrying"})
160+ return
161+ }
162+ if err := client.Call(ctx, method, params, result); err != nil {
163+ s.writeObservationError(id, err)
164+ return
165+ }
166+ s.writeToolResult(id, toolCallResult{Content: []toolContentItem{{Type: "text", Text: "Bounded read-only daemon observation; inspect structuredContent for coverage and unavailable values."}}, StructuredContent: result})
167+}
168+func (s *Server) writeObservationError(id *json.RawMessage, err error) {
169+ var query observability.QueryError
170+ if errors.As(err, &query) {
171+ code := -32000
172+ if query.Code == "invalid_query" || query.Code == "invalid_cursor" {
173+ code = -32602
174+ }
175+ s.writeError(id, code, "Observation unavailable", &errorData{Code: query.Code, Message: "bounded observation request could not be completed", Observation: &query})
176+ return
177+ }
178+ var domain servicectl.RPCDomainError
179+ if errors.As(err, &domain) {
180+ data := &errorData{Code: domain.Code, Operation: "service_observation", Message: "bounded local observation is unavailable"}
181+ switch domain.Code {
182+ case "unsupported_capability":
183+ data.Message = "daemon does not support observability.v1"
184+ data.Remediation = "explicitly update the daemon, then retry; this read never updates it"
185+ case "service_unavailable":
186+ data.Message = "local daemon is unavailable"
187+ data.Remediation = "explicitly start the daemon, then retry; this read never starts it"
188+ case "observation_timeout":
189+ data.Message = "local observation read timed out"
190+ data.Remediation = "retry the bounded read only if still needed"
191+ case "observation_cancelled":
192+ data.Message = "local observation read was cancelled"
193+ data.Remediation = "retry only if still needed"
194+ case "observation_busy":
195+ data.Message = "local observation snapshot is busy"
196+ data.Remediation = "retry the read without changing execution state"
197+ case "response_limit", "observation_unavailable", "invalid_response":
198+ data.Remediation = "inspect daemon compatibility and retry a bounded read"
199+ default:
200+ data.Code = "invalid_response"
201+ data.Remediation = "inspect daemon compatibility before retrying"
202+ }
203+ s.writeError(id, -32000, "Observation unavailable", data)
204+ return
205+ }
206+ s.writeOperationalError(id, err, domainErrorContext{Operation: "service_observation", Subsystem: "service"})
207+}
@@ -0,0 +1,369 @@
1+package mcp
2+ 
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "io"
8+ "net"
9+ "net/http/httptest"
10+ "reflect"
11+ "strings"
12+ "sync/atomic"
13+ "testing"
14+ "time"
15+ 
16+ "gitcode-mcp/internal/observability"
17+ "gitcode-mcp/internal/servicectl"
18+)
19+ 
20+func assertMinimalObservationTools(t *testing.T, tools []toolDefinition) {
21+ t.Helper()
22+ want := map[string]bool{"doctor": true, "service_observability": true, "service_metrics": true, "service_events": true}
23+ if len(tools) != len(want) {
24+ t.Fatal("minimal discovery lost its fixed four-tool contract")
25+ }
26+ for _, tool := range tools {
27+ if !want[tool.Name] {
28+ t.Fatal("unexpected minimal tool")
29+ }
30+ delete(want, tool.Name)
31+ }
32+ if len(want) != 0 {
33+ t.Fatal("minimal discovery contains duplicate tools")
34+ }
35+}
36+ 
37+func observationMCPFixture(t *testing.T) (*servicectl.RPCClient, *observability.Reader) {
38+ t.Helper()
39+ at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
40+ c := observability.New(observability.Config{Directory: t.TempDir(), ManagedOutput: true})
41+ if !c.Close(context.Background(), true) {
42+ t.Fatal("fixture collector did not close")
43+ }
44+ m := observability.NewMetrics(c.BootID(), at)
45+ if !m.SampleAt(at, map[string]observability.ProbeValue{"runtime_heap": observability.Available(42)}) {
46+ t.Fatal("actual metric sample failed")
47+ }
48+ r := &observability.Reader{Collector: c, Metrics: m, Now: func() time.Time { return at }}
49+ ctx, cancel := context.WithCancel(context.Background())
50+ listener, err := net.Listen("tcp", "127.0.0.1:0")
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ done := make(chan error, 1)
55+ go func() { done <- (servicectl.RPCServer{Observability: r}).Serve(ctx, listener) }()
56+ t.Cleanup(func() {
57+ cancel()
58+ _ = listener.Close()
59+ select {
60+ case <-done:
61+ case <-time.After(time.Second):
62+ t.Error("observation fixture worker did not unwind")
63+ }
64+ })
65+ return &servicectl.RPCClient{Network: "tcp", Address: listener.Addr().String()}, r
66+}
67+ 
68+func mcpObservationCall(t *testing.T, h *RPCHandler, name, args string) *response {
69+ t.Helper()
70+ params := json.RawMessage(`{"name":` + strconvJSON(name) + `,"arguments":` + args + `}`)
71+ id := json.RawMessage(`1`)
72+ resp, ok := h.Handle(context.Background(), request{JSONRPC: "2.0", ID: &id, Method: "tools/call", Params: &params})
73+ if !ok || resp == nil {
74+ t.Fatal("missing observation response")
75+ }
76+ return resp
77+}
78+func strconvJSON(s string) string { b, _ := json.Marshal(s); return string(b) }
79+ 
80+func TestMCPObservationStrictBeforeClientOrBootstrap(t *testing.T) {
81+ var clientCalls, bootCalls atomic.Int64
82+ h := NewRPCHandler(nil)
83+ h.SetToolBootstrap(func(context.Context, string, json.RawMessage) (*RPCHandler, error) { bootCalls.Add(1); return nil, nil })
84+ h.SetServiceClient(func() (*servicectl.RPCClient, error) {
85+ clientCalls.Add(1)
86+ return &servicectl.RPCClient{Network: "mem", Address: "absent"}, nil
87+ })
88+ for _, test := range []struct{ name, args string }{
89+ {"service_observability", `{"repo_id":"synthetic-private"}`},
90+ {"service_observability", `null`},
91+ {"service_metrics", `{"name":"synthetic-private"}`},
92+ {"service_metrics", `{"history_minutes":0}`},
93+ {"service_metrics", `{"history_minutes":1,"history_minutes":2}`},
94+ {"service_events", `{"limit":0}`},
95+ {"service_events", `{"limit":1.5}`},
96+ {"service_events", `{"source_ref":"synthetic-private"}`},
97+ {"service_events", `{"since":"2026-01-01T00:00:00Z"}`},
98+ {"service_events", `{"limit":50,"limit":10}`},
99+ {"service_events", `{"write_mode":"live"}`},
100+ {"service_events", `{"cursor":null}`},
101+ {"service_events", `{"stream":"stderr","code":"job_terminal"}`},
102+ {"service_events", `{"LIMIT":1}`},
103+ {"service_events", `{"limit":0,"LIMIT":1}`},
104+ {"service_metrics", `{"NAME":"runtime_heap"}`},
105+ } {
106+ resp := mcpObservationCall(t, h, test.name, test.args)
107+ if resp.Error == nil || resp.Error.Code != -32602 || resp.Error.Data.Code != "invalid_query" {
108+ t.Fatal("invalid observation query accepted")
109+ }
110+ b, _ := json.Marshal(resp)
111+ if strings.Contains(string(b), "synthetic-private") {
112+ t.Fatal("invalid query echoed private input")
113+ }
114+ }
115+ for _, raw := range []string{`{"name":"service_events","name":"service_events","arguments":{}}`, `{"name":"service_events","extra":1}`, `{"name":"service_events","arguments":{},"arguments":{}}`} {
116+ params := json.RawMessage(raw)
117+ id := json.RawMessage(`1`)
118+ resp, ok := h.Handle(context.Background(), request{JSONRPC: "2.0", ID: &id, Method: "tools/call", Params: &params})
119+ if !ok || resp.Error == nil || resp.Error.Code != -32602 {
120+ t.Fatal("invalid observation envelope accepted")
121+ }
122+ }
123+ if clientCalls.Load() != 0 || bootCalls.Load() != 0 {
124+ t.Fatal("invalid arguments reached local client or bootstrap")
125+ }
126+}
127+ 
128+func assertObservationMCPParity(t *testing.T, r *observability.Reader, call func(string, string) *response) {
129+ t.Helper()
130+ wantSummary, err := r.Summary(context.Background())
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ wantMetrics, err := r.MetricSnapshot(context.Background(), observability.MetricsRequest{Name: "runtime_heap", HistoryMinutes: 3})
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ wantEvents, err := r.Events(context.Background(), observability.EventsRequest{Limit: 50})
139+ if err != nil {
140+ t.Fatal(err)
141+ }
142+ for _, test := range []struct {
143+ name, args string
144+ want any
145+ }{
146+ {"service_observability", `{}`, wantSummary},
147+ {"service_metrics", `{"name":"runtime_heap","history_minutes":3}`, wantMetrics},
148+ {"service_events", `{}`, wantEvents},
149+ } {
150+ resp := call(test.name, test.args)
151+ if resp.Error != nil {
152+ t.Fatalf("%s failed: %s", test.name, resp.Error.Data.Code)
153+ }
154+ var result struct {
155+ Structured json.RawMessage `json:"structuredContent"`
156+ }
157+ if json.Unmarshal(resp.Result, &result) != nil {
158+ t.Fatal("invalid tool result")
159+ }
160+ var got, want any
161+ _ = json.Unmarshal(result.Structured, &got)
162+ b, _ := json.Marshal(test.want)
163+ _ = json.Unmarshal(b, &want)
164+ if !reflect.DeepEqual(got, want) {
165+ t.Fatalf("%s differs from shared reader", test.name)
166+ }
167+ }
168+}
169+ 
170+func TestMCPObservationNormalMinimalAndStdioParity(t *testing.T) {
171+ client, r := observationMCPFixture(t)
172+ for _, minimal := range []bool{false, true} {
173+ h := NewRPCHandler(nil)
174+ if minimal {
175+ h = NewMinimalRPCHandler(StartupDiagnostic{ErrorClass: "schema_incompatible", Message: "cache is incompatible", Remediation: "explicitly migrate cache"})
176+ }
177+ h.SetServiceClient(func() (*servicectl.RPCClient, error) { return client, nil })
178+ h.SetToolBootstrap(func(context.Context, string, json.RawMessage) (*RPCHandler, error) {
179+ t.Error("observation invoked bootstrap")
180+ return nil, nil
181+ })
182+ assertObservationMCPParity(t, r, func(name, args string) *response { return mcpObservationCall(t, h, name, args) })
183+ var input strings.Builder
184+ for _, name := range []string{"service_observability", "service_metrics", "service_events"} {
185+ input.WriteString(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":` + strconvJSON(name) + `,"arguments":{}}}` + "\n")
186+ }
187+ var output bytes.Buffer
188+ s := NewWithToolAccess(strings.NewReader(input.String()), &output, io.Discard, nil, nil, ToolAccessRead)
189+ if minimal {
190+ s = NewMinimal(strings.NewReader(input.String()), &output, io.Discard, StartupDiagnostic{ErrorClass: "cache_path_unwritable"})
191+ }
192+ s.SetServiceClient(func() (*servicectl.RPCClient, error) { return client, nil })
193+ if s.Serve() != nil {
194+ t.Fatal("stdio observation serve failed")
195+ }
196+ lines := strings.Split(strings.TrimSpace(output.String()), "\n")
197+ if len(lines) != 3 {
198+ t.Fatal("stdio observation lost responses")
199+ }
200+ for _, line := range lines {
201+ var resp response
202+ if json.Unmarshal([]byte(line), &resp) != nil || resp.Error != nil {
203+ t.Fatal("stdio observation failed")
204+ }
205+ }
206+ }
207+}
208+ 
209+func TestMCPObservationHTTPSSEParityDuringCacheBootstrap(t *testing.T) {
210+ client, r := observationMCPFixture(t)
211+ h := NewRPCHandler(nil)
212+ h.SetServiceClient(func() (*servicectl.RPCClient, error) { return client, nil })
213+ started, release := make(chan struct{}), make(chan struct{})
214+ h.SetToolBootstrap(func(context.Context, string, json.RawMessage) (*RPCHandler, error) {
215+ close(started)
216+ <-release
217+ return nil, context.Canceled
218+ })
219+ done := make(chan struct{})
220+ go func() {
221+ defer close(done)
222+ id := json.RawMessage(`1`)
223+ params := json.RawMessage(`{"name":"cache_status","arguments":{}}`)
224+ _, _ = h.Handle(context.Background(), request{JSONRPC: "2.0", ID: &id, Method: "tools/call", Params: &params})
225+ }()
226+ defer func() {
227+ close(release)
228+ select {
229+ case <-done:
230+ case <-time.After(time.Second):
231+ t.Error("bootstrap fixture worker did not unwind")
232+ }
233+ }()
234+ select {
235+ case <-started:
236+ case <-time.After(time.Second):
237+ t.Fatal("bootstrap fixture did not start")
238+ }
239+ transport := NewHTTPSSETransport(h, ServerConfig{SessionID: func() string { return "observation-session" }})
240+ server := httptest.NewServer(transport.Handler())
241+ defer server.Close()
242+ sse, endpoint, events := openSSE(t, server.URL+"/sse")
243+ defer sse.Body.Close()
244+ assertObservationMCPParity(t, r, func(name, args string) *response {
245+ postJSON(t, server.URL+endpoint, map[string]any{"jsonrpc": "2.0", "id": name, "method": "tools/call", "params": map[string]any{"name": name, "arguments": json.RawMessage(args)}})
246+ response := readSSEMessage(t, events)
247+ return &response
248+ })
249+}
250+ 
251+func TestMCPObservationDiscoveryFiniteReadOnlySchemas(t *testing.T) {
252+ h := NewRPCHandler(nil)
253+ h.SetToolBootstrap(func(context.Context, string, json.RawMessage) (*RPCHandler, error) {
254+ t.Fatal("discovery bootstrapped dependencies")
255+ return nil, nil
256+ })
257+ id := json.RawMessage(`1`)
258+ resp, ok := h.Handle(context.Background(), request{JSONRPC: "2.0", ID: &id, Method: "tools/list"})
259+ if !ok || resp.Error != nil {
260+ t.Fatal("discovery unavailable")
261+ }
262+ var listed struct {
263+ Tools []toolDefinition `json:"tools"`
264+ }
265+ if json.Unmarshal(resp.Result, &listed) != nil {
266+ t.Fatal("discovery result malformed")
267+ }
268+ found := 0
269+ for _, d := range listed.Tools {
270+ if _, ok := observationToolDefinition(d.Name); !ok {
271+ continue
272+ }
273+ found++
274+ if d.InputSchema.AdditionalProperties == nil || *d.InputSchema.AdditionalProperties || !d.Annotations["readOnlyHint"] || d.Annotations["destructiveHint"] || !d.Annotations["idempotentHint"] || d.Annotations["openWorldHint"] {
275+ t.Fatal("observation discovery lost strict read-only hints")
276+ }
277+ if d.Name == "service_metrics" && len(d.InputSchema.Properties["name"].Enum) != len(observability.MetricCatalog()) {
278+ t.Fatal("metric discovery lost finite catalog")
279+ }
280+ if d.Name == "service_events" {
281+ for key, values := range observability.FilterValues() {
282+ if !reflect.DeepEqual(d.InputSchema.Properties[key].Enum, values) {
283+ t.Fatal("event discovery diverged from admitted filter catalog")
284+ }
285+ }
286+ }
287+ }
288+ if found != 3 {
289+ t.Fatal("normal discovery lost observation tools")
290+ }
291+}
292+ 
293+func TestMCPObservationHTTPSSEMinimalParity(t *testing.T) {
294+ client, reader := observationMCPFixture(t)
295+ h := NewMinimalRPCHandler(StartupDiagnostic{ErrorClass: "schema_incompatible"})
296+ h.SetServiceClient(func() (*servicectl.RPCClient, error) { return client, nil })
297+ transport := NewHTTPSSETransport(h, ServerConfig{SessionID: func() string { return "minimal-observation" }})
298+ server := httptest.NewServer(transport.Handler())
299+ defer server.Close()
300+ sse, endpoint, messages := openSSE(t, server.URL+"/sse")
301+ defer sse.Body.Close()
302+ postJSON(t, server.URL+endpoint, map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
303+ listed := readSSEMessage(t, messages)
304+ var result struct {
305+ Tools []toolDefinition `json:"tools"`
306+ }
307+ if listed.Error != nil || json.Unmarshal(listed.Result, &result) != nil {
308+ t.Fatal("minimal HTTP discovery failed")
309+ }
310+ assertMinimalObservationTools(t, result.Tools)
311+ assertObservationMCPParity(t, reader, func(name, args string) *response {
312+ postJSON(t, server.URL+endpoint, map[string]any{"jsonrpc": "2.0", "id": name, "method": "tools/call", "params": map[string]any{"name": name, "arguments": json.RawMessage(args)}})
313+ resp := readSSEMessage(t, messages)
314+ return &resp
315+ })
316+}
317+ 
318+func TestMCPObservationMissingAndOldDaemonExplicitHandoffs(t *testing.T) {
319+ for _, mode := range []string{"missing", "old"} {
320+ h := NewRPCHandler(nil)
321+ if mode == "missing" {
322+ h.SetServiceClient(func() (*servicectl.RPCClient, error) {
323+ return &servicectl.RPCClient{Network: "mem", Address: "absent-observation-daemon"}, nil
324+ })
325+ } else {
326+ listener, err := net.Listen("tcp", "127.0.0.1:0")
327+ if err != nil {
328+ t.Fatal("old daemon fixture unavailable")
329+ }
330+ done := make(chan struct{})
331+ go func() {
332+ defer close(done)
333+ conn, err := listener.Accept()
334+ if err != nil {
335+ return
336+ }
337+ defer conn.Close()
338+ _ = conn.SetDeadline(time.Now().Add(time.Second))
339+ var req servicectl.RPCRequest
340+ if json.NewDecoder(conn).Decode(&req) == nil {
341+ _ = json.NewEncoder(conn).Encode(servicectl.RPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &servicectl.RPCError{Code: -32000, Message: "synthetic-private-old-method"}})
342+ }
343+ }()
344+ t.Cleanup(func() {
345+ _ = listener.Close()
346+ select {
347+ case <-done:
348+ case <-time.After(time.Second):
349+ t.Error("old daemon fixture did not join")
350+ }
351+ })
352+ h.SetServiceClient(func() (*servicectl.RPCClient, error) {
353+ return &servicectl.RPCClient{Network: "tcp", Address: listener.Addr().String()}, nil
354+ })
355+ }
356+ resp := mcpObservationCall(t, h, "service_observability", `{}`)
357+ wantCode, action := "service_unavailable", "explicitly start"
358+ if mode == "old" {
359+ wantCode, action = "unsupported_capability", "explicitly update"
360+ }
361+ if resp.Error == nil || resp.Error.Data.Code != wantCode || !strings.Contains(resp.Error.Data.Remediation, action) {
362+ t.Fatal("observation failure lost explicit non-mutating handoff")
363+ }
364+ data, _ := json.Marshal(resp)
365+ if strings.Contains(string(data), "synthetic-private") {
366+ t.Fatal("observation echoed old daemon body")
367+ }
368+ }
369+}
@@ -198,6 +198,23 @@ func (c *Collector) Coverage() Coverage {
198 c.absorbLossLocked()198 c.absorbLossLocked()
199 return c.coverage199 return c.coverage
200}200}
201+ 
202+// CoverageContext never waits for the writer or folds loss after cancellation.
203+// It does not start ingestion, probe managed output, or create a read worker.
204+func (c *Collector) CoverageContext(ctx context.Context) (Coverage, error) {
205+ if err := ctx.Err(); err != nil {
206+ return Coverage{}, err
207+ }
208+ if !c.mu.TryLock() {
209+ return Coverage{}, QueryError{Code: "observation_busy", Reason: "ledger_busy"}
210+ }
211+ defer c.mu.Unlock()
212+ if err := ctx.Err(); err != nil {
213+ return Coverage{}, err
214+ }
215+ c.absorbLossLocked()
216+ return c.coverage, nil
217+}
201func (c *Collector) Close(ctx context.Context, workersUnwound bool) bool {218func (c *Collector) Close(ctx context.Context, workersUnwound bool) bool {
202 c.mu.Lock()219 c.mu.Lock()
203 if !c.closing {220 if !c.closing {
@@ -0,0 +1,19 @@
1+package observability
2+ 
3+// EntityRefs is an additive bridge on already-listed local resources. It does
4+// not authorize a lookup and never substitutes a private/public entity id.
5+type EntityRefs struct {
6+ Job string `json:"job_ref,omitempty"`
7+ Registration string `json:"registration_ref,omitempty"`
8+ Cache string `json:"cache_ref,omitempty"`
9+ Repo string `json:"repo_ref,omitempty"`
10+ Source string `json:"source_ref,omitempty"`
11+}
12+ 
13+func ProjectEntityRefs(known Context) *EntityRefs {
14+ refs := EntityRefs{known.Job.value, known.Registration.value, known.Cache.value, known.Repo.value, known.Source.value}
15+ if refs == (EntityRefs{}) {
16+ return nil
17+ }
18+ return &refs
19+}
Ainternal/observability/filter.go+185-0文件内容审核中,请稍后刷新重试
Ainternal/observability/filter_test.go+126-0文件内容审核中,请稍后刷新重试
@@ -1,6 +1,7 @@
1package observability1package observability
2 2 
3import (3import (
4+ "context"
4 "sort"5 "sort"
5 "time"6 "time"
6)7)
@@ -33,6 +34,7 @@ type Incident struct {
33 RegistrationRef string `json:"registration_ref,omitempty"`34 RegistrationRef string `json:"registration_ref,omitempty"`
34 CacheRef string `json:"cache_ref,omitempty"`35 CacheRef string `json:"cache_ref,omitempty"`
35 RepoRef string `json:"repo_ref,omitempty"`36 RepoRef string `json:"repo_ref,omitempty"`
37+ SourceRef string `json:"source_ref,omitempty"`
36 Coverage string `json:"coverage"`38 Coverage string `json:"coverage"`
37}39}
38type ExecutionHealth struct {40type ExecutionHealth struct {
@@ -68,12 +70,23 @@ type incidentCohort struct {
68// reconstructed from retained durable evidence on every read, also after boot.70// reconstructed from retained durable evidence on every read, also after boot.
69// This deliberately makes no claim about pruned jobs or unobserved work.71// This deliberately makes no claim about pruned jobs or unobserved work.
70func ProjectExecutionHealth(at time.Time, evidence []HealthEvidence) ExecutionHealth {72func ProjectExecutionHealth(at time.Time, evidence []HealthEvidence) ExecutionHealth {
73+ out, _ := ProjectExecutionHealthContext(context.Background(), at, evidence)
74+ return out
75+}
76+ 
77+func ProjectExecutionHealthContext(ctx context.Context, at time.Time, evidence []HealthEvidence) (ExecutionHealth, error) {
78+ if err := ctx.Err(); err != nil {
79+ return ExecutionHealth{}, err
80+ }
71 if len(evidence) > MaxHealthEvidence {81 if len(evidence) > MaxHealthEvidence {
72- return UnavailableExecutionHealth(at, "evidence_limit")82+ return UnavailableExecutionHealth(at, "evidence_limit"), nil
73 }83 }
74 out := ExecutionHealth{Schema: 1, ObservedAt: at.UTC(), State: "healthy", Coverage: "retained", Reason: "durable_retention_boundary", Source: "durable_jobs_and_maintenance", Limit: MaxIncidents, Incidents: []Incident{}}84 out := ExecutionHealth{Schema: 1, ObservedAt: at.UTC(), State: "healthy", Coverage: "retained", Reason: "durable_retention_boundary", Source: "durable_jobs_and_maintenance", Limit: MaxIncidents, Incidents: []Incident{}}
75 cohorts := make(map[string]*incidentCohort)85 cohorts := make(map[string]*incidentCohort)
76 for _, e := range evidence {86 for _, e := range evidence {
87+ if err := ctx.Err(); err != nil {
88+ return ExecutionHealth{}, err
89+ }
77 if !validHealthEvidence(e) {90 if !validHealthEvidence(e) {
78 out.Coverage, out.Reason = "partial", "invalid_authority_evidence"91 out.Coverage, out.Reason = "partial", "invalid_authority_evidence"
79 continue92 continue
@@ -111,12 +124,15 @@ func ProjectExecutionHealth(at time.Time, evidence []HealthEvidence) ExecutionHe
111 c.hasProblem = true124 c.hasProblem = true
112 }125 }
113 for key, c := range cohorts {126 for key, c := range cohorts {
127+ if err := ctx.Err(); err != nil {
128+ return ExecutionHealth{}, err
129+ }
114 if !c.hasProblem {130 if !c.hasProblem {
115 continue131 continue
116 }132 }
117 e := c.problem133 e := c.problem
118 i := Incident{IncidentRef: key, Origin: e.Origin, Collection: e.Collection, Phase: e.Phase, State: "active", Reason: e.Reason, FirstObservedAt: c.first, LastObservedAt: c.last,134 i := Incident{IncidentRef: key, Origin: e.Origin, Collection: e.Collection, Phase: e.Phase, State: "active", Reason: e.Reason, FirstObservedAt: c.first, LastObservedAt: c.last,
119- JobRef: e.Refs.Job.value, RegistrationRef: e.Refs.Registration.value, CacheRef: e.Refs.Cache.value, RepoRef: e.Refs.Repo.value, Coverage: "retained"}135+ JobRef: e.Refs.Job.value, RegistrationRef: e.Refs.Registration.value, CacheRef: e.Refs.Cache.value, RepoRef: e.Refs.Repo.value, SourceRef: e.Refs.Source.value, Coverage: "retained"}
120 if e.Coverage == "partial" {136 if e.Coverage == "partial" {
121 i.Coverage = "partial"137 i.Coverage = "partial"
122 }138 }
@@ -160,7 +176,10 @@ func ProjectExecutionHealth(at time.Time, evidence []HealthEvidence) ExecutionHe
160 if out.State == "healthy" && out.Coverage == "partial" {176 if out.State == "healthy" && out.Coverage == "partial" {
161 out.State = "unknown"177 out.State = "unknown"
162 }178 }
163- return out179+ if err := ctx.Err(); err != nil {
180+ return ExecutionHealth{}, err
181+ }
182+ return out, nil
164}183}
165 184 
166func laterHealthTie(a, b HealthEvidence) bool {185func laterHealthTie(a, b HealthEvidence) bool {
@@ -237,5 +256,5 @@ func validHealthEvidence(e HealthEvidence) bool {
237 default:256 default:
238 return false257 return false
239 }258 }
240- return validRef(e.Refs.Job.value, "job") && validRef(e.Refs.Registration.value, "registration") && validRef(e.Refs.Cache.value, "cache") && validRef(e.Refs.Repo.value, "repo") && validRef(e.Refs.Correlation.value, "correlation")259+ return validRef(e.Refs.Job.value, "job") && validRef(e.Refs.Registration.value, "registration") && validRef(e.Refs.Cache.value, "cache") && validRef(e.Refs.Repo.value, "repo") && validRef(e.Refs.Correlation.value, "correlation") && validRef(e.Refs.Source.value, "source") && (e.Refs.Source.value == "" || e.Origin == "repository_docs")
241}260}
@@ -1,6 +1,7 @@
1package observability1package observability
2 2 
3import (3import (
4+ "context"
4 "math"5 "math"
5 "sort"6 "sort"
6 "strings"7 "strings"
@@ -405,6 +406,37 @@ func metricHistoryIndex(minute int64) int {
405func (m *Metrics) Snapshot() MetricsSnapshot {406func (m *Metrics) Snapshot() MetricsSnapshot {
406 m.mu.Lock()407 m.mu.Lock()
407 defer m.mu.Unlock()408 defer m.mu.Unlock()
409+ out, _ := m.snapshotLocked(context.Background(), true)
410+ return out
411+}
412+ 
413+// SnapshotContext is a non-waiting read of the existing sampled history. It
414+// neither samples probes nor counts a rejected read as lost execution evidence.
415+func (m *Metrics) SnapshotContext(ctx context.Context) (MetricsSnapshot, error) {
416+ return m.readSnapshot(ctx, true)
417+}
418+ 
419+// CurrentSnapshotContext avoids copying sampled history for summary polls.
420+// Availability and fixed current points are identical to the full snapshot.
421+func (m *Metrics) CurrentSnapshotContext(ctx context.Context) (MetricsSnapshot, error) {
422+ return m.readSnapshot(ctx, false)
423+}
424+ 
425+func (m *Metrics) readSnapshot(ctx context.Context, history bool) (MetricsSnapshot, error) {
426+ if err := ctx.Err(); err != nil {
427+ return MetricsSnapshot{}, err
428+ }
429+ if !m.mu.TryLock() {
430+ return MetricsSnapshot{}, QueryError{Code: "observation_busy", Reason: "metrics_busy"}
431+ }
432+ defer m.mu.Unlock()
433+ return m.snapshotLocked(ctx, history)
434+}
435+ 
436+func (m *Metrics) snapshotLocked(ctx context.Context, history bool) (MetricsSnapshot, error) {
437+ if err := ctx.Err(); err != nil {
438+ return MetricsSnapshot{}, err
439+ }
408 m.refreshCollectorCounterLocked()440 m.refreshCollectorCounterLocked()
409 out := MetricsSnapshot{BootID: m.boot, ResetAt: m.resetAt, SampledAt: m.sampledAt, State: "ready", Dropped: m.dropped.Load(), Catalog: MetricCatalog(), Series: make([]MetricSeries, 0, len(m.keys))}441 out := MetricsSnapshot{BootID: m.boot, ResetAt: m.resetAt, SampledAt: m.sampledAt, State: "ready", Dropped: m.dropped.Load(), Catalog: MetricCatalog(), Series: make([]MetricSeries, 0, len(m.keys))}
410 if m.sampledAt.IsZero() {442 if m.sampledAt.IsZero() {
@@ -413,6 +445,9 @@ func (m *Metrics) Snapshot() MetricsSnapshot {
413 out.State = "partial"445 out.State = "partial"
414 }446 }
415 for _, key := range m.keys {447 for _, key := range m.keys {
448+ if err := ctx.Err(); err != nil {
449+ return MetricsSnapshot{}, err
450+ }
416 c := m.cells[key]451 c := m.cells[key]
417 point := MetricPoint{Name: c.definition.Name, Labels: copyLabels(c.labels), Reason: c.reason, Count: c.count, Sum: c.sum}452 point := MetricPoint{Name: c.definition.Name, Labels: copyLabels(c.labels), Reason: c.reason, Count: c.count, Sum: c.sum}
418 if c.available && c.definition.Type != "histogram" {453 if c.available && c.definition.Type != "histogram" {
@@ -423,7 +458,7 @@ func (m *Metrics) Snapshot() MetricsSnapshot {
423 point.Distribution = append([]uint64(nil), c.distribution...)458 point.Distribution = append([]uint64(nil), c.distribution...)
424 }459 }
425 series := MetricSeries{MetricPoint: point, History: make([]MetricBucket, 0, MetricHistoryMinutes)}460 series := MetricSeries{MetricPoint: point, History: make([]MetricBucket, 0, MetricHistoryMinutes)}
426- if !m.sampledAt.IsZero() {461+ if history && !m.sampledAt.IsZero() {
427 latest := m.sampledAt.UTC().Truncate(time.Minute)462 latest := m.sampledAt.UTC().Truncate(time.Minute)
428 for i := MetricHistoryMinutes - 1; i >= 0; i-- {463 for i := MetricHistoryMinutes - 1; i >= 0; i-- {
429 start := latest.Add(-time.Duration(i) * time.Minute)464 start := latest.Add(-time.Duration(i) * time.Minute)
@@ -444,5 +479,8 @@ func (m *Metrics) Snapshot() MetricsSnapshot {
444 }479 }
445 out.Series = append(out.Series, series)480 out.Series = append(out.Series, series)
446 }481 }
447- return out482+ if err := ctx.Err(); err != nil {
483+ return MetricsSnapshot{}, err
484+ }
485+ return out, nil
448}486}
@@ -27,7 +27,7 @@ func ProjectObservationHealth(coverage Coverage, metrics MetricsSnapshot) Observ
27 out.OutputState = coverage.OutputState27 out.OutputState = coverage.OutputState
28 }28 }
29 switch metrics.State {29 switch metrics.State {
30- case "ready", "partial":30+ case "initializing", "ready", "partial":
31 out.MetricsState = metrics.State31 out.MetricsState = metrics.State
32 }32 }
33 if !metrics.SampledAt.IsZero() {33 if !metrics.SampledAt.IsZero() {
Minternal/servicectl/execution_health.go+28-6文件内容审核中,请稍后刷新重试
Minternal/servicectl/ipc.go+78-25文件内容审核中,请稍后刷新重试