已合并
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
已合并
共 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 | ||
| 3 | import ( | 3 | import ( |
| 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 0 | 112 | 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.Config | 163 | 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.live | 169 | 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.Token | 173 | 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.offline | 177 | 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 | ||
| 506 | func newMCPStdioServer(ctx context.Context, stdin io.Reader, stdout io.Writer, stderr io.Writer, deps StartupDeps) (*mcp.Server, func()) { | 535 | func 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 | ||
| 534 | func runMCPHTTPSSE(ctx context.Context, stderr io.Writer, deps StartupDeps, bind string) int { | 546 | func 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 1 | 552 | 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 := mcpRoute | 1340 | 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. | |
| @@ -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 exposed | 223 | ### 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. | |||
| 199 | Registered observability polling emits no self-events; unsupported/user-prefix | 199 | Registered observability polling emits no self-events; unsupported/user-prefix |
| 200 | paths remain observable ordinary requests. Synthetic control, cookie, body, | 200 | paths remain observable ordinary requests. Synthetic control, cookie, body, |
| 201 | header, request-id and URL sentinels must be absent from retained evidence. | 201 | header, 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). | ||
| 202 | Maintenance identity tests also enroll canonical and alias repository ids | 217 | Maintenance identity tests also enroll canonical and alias repository ids |
| 203 | concurrently, migrate compatible and conflicting version-1 registry entries, | 218 | concurrently, migrate compatible and conflicting version-1 registry entries, |
| 204 | project legacy registration/job references to the canonical identity, preserve | 219 | project legacy registration/job references to the canonical identity, preserve |
| @@ -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 | ||
| 22 | const observationAPIVersion = "1" | 23 | const observationAPIVersion = "1" |
| @@ -90,6 +91,7 @@ type AttentionItem struct { | |||
| 90 | } | 91 | } |
| 91 | 92 | ||
| 92 | type CacheObservation struct { | 93 | type 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 | ||
| 131 | type RepositoryObservation struct { | 133 | type 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 | ||
| 145 | type RepositoryDocumentationObservation struct { | 148 | type 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 | ||
| 193 | type RepositoryDocumentationSource struct { | 197 | type 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 | ||
| 203 | type RepositoryDocumentationExclusionCount struct { | 208 | type RepositoryDocumentationExclusionCount struct { |
| @@ -287,6 +292,7 @@ type StageError struct { | |||
| 287 | } | 292 | } |
| 288 | 293 | ||
| 289 | type JobObservation struct { | 294 | type 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 | ||
| 378 | type MaintenanceObservation struct { | 384 | type 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 must | 133 | // 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 RAGRepairProvider | 81 | ApplyRAGRepair RAGRepairProvider |
| 82 | Metrics *observability.Metrics | 82 | Metrics *observability.Metrics |
| 83 | Observation *observability.Collector | 83 | Observation *observability.Collector |
| 84 | + Observability *observability.Reader | ||
| 84 | } | 85 | } |
| 85 | 86 | ||
| 86 | type Controller struct { | 87 | type 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, nil | 159 | 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 | ||
| 3 | import ( | 3 | import ( |
| 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 | ||
| 21 | type CredentialResolver struct { | 22 | type CredentialResolver struct { |
| 23 | + mu sync.Mutex | ||
| 22 | provider config.CredentialProvider | 24 | provider config.CredentialProvider |
| 23 | result *Result | 25 | result *Result |
| 24 | } | 26 | } |
| @@ -34,11 +36,24 @@ func NewCredentialResolverWithProvider(provider config.CredentialProvider) *Cred | |||
| 34 | } | 36 | } |
| 35 | 37 | ||
| 36 | func (r *CredentialResolver) Resolve(ctx context.Context, eff config.EffectiveConfig) Result { | 38 | func (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.result | 50 | 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 result | 66 | 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 | + | ||
| 54 | func (r *CredentialResolver) Status(ctx context.Context, eff config.EffectiveConfig) Result { | 73 | func (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 | |||
| 3 | import ( | 3 | import ( |
| 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 | ||
| 385 | func executeWithFactoryAndDepsContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, factory serviceFactory, deps localCommandDeps) int { | 385 | func 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 | ||
| 100 | type RPCHandler struct { | 101 | type RPCHandler struct { |
| 102 | + toolRelease func() | ||
| 103 | + bootstrapDiagnostic func() StartupDiagnostic | ||
| 104 | + toolBootstrap ToolBootstrap | ||
| 105 | + serviceClient func() (*servicectl.RPCClient, error) | ||
| 101 | svc serviceInterface | 106 | svc serviceInterface |
| 102 | startupDiagnostic StartupDiagnostic | 107 | startupDiagnostic StartupDiagnostic |
| 103 | minimal bool | 108 | minimal bool |
| @@ -114,6 +119,8 @@ type RPCHandler struct { | |||
| 114 | } | 119 | } |
| 115 | 120 | ||
| 116 | type Server struct { | 121 | type Server struct { |
| 122 | + bootstrapDiagnostic func() StartupDiagnostic | ||
| 123 | + toolBootstrap ToolBootstrap | ||
| 117 | reader io.Reader | 124 | reader io.Reader |
| 118 | writer io.Writer | 125 | writer io.Writer |
| 119 | stderr io.Writer | 126 | stderr io.Writer |
| @@ -248,6 +255,10 @@ func (h *RPCHandler) Handle(ctx context.Context, req request) (*response, bool) | |||
| 248 | } | 255 | } |
| 249 | var buf bytes.Buffer | 256 | 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 | ||
| 283 | type errorData struct { | 294 | type 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 | ||
| 301 | type toolDefinition struct { | 313 | type 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 | ||
| 308 | type inputSchema struct { | 321 | type 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 | ||
| 314 | type schemaProp struct { | 328 | type schemaProp struct { |
| @@ -902,6 +916,11 @@ func (s *Server) toolsList(ctx context.Context, req request) { | |||
| 902 | } | 916 | } |
| 903 | definition := tool.definition | 917 | 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 | return | 962 | return |
| 944 | } | 963 | } |
| 964 | + if _, observation := observationToolDefinition(params.Name); observation { | ||
| 965 | + if err := decodeObservationCall(*req.Params, ¶ms); 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 | return | 989 | 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 | ||
| 1018 | func toolDefinitionByName(name string) toolDefinition { | 1069 | func 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 def | 1075 | return def |
| @@ -1058,6 +1112,9 @@ func (s *Server) ragToolHandler(cap capability.Capability) toolHandler { | |||
| 1058 | 1112 | ||
| 1059 | func (s *Server) toolRegistry() toolRegistry { | 1113 | func (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 registry | 1120 | return registry |
| @@ -2595,8 +2652,13 @@ func classifyDomainError(err error, ctx domainErrorContext) *errorData { | |||
| 2595 | var schemaErr *cache.SchemaVersionError | 2652 | var schemaErr *cache.SchemaVersionError |
| 2596 | var parentMissing service.ErrParentPRNotCached | 2653 | var parentMissing service.ErrParentPRNotCached |
| 2597 | var rpcErr servicectl.RPCDomainError | 2654 | 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, ¬Found) && notFound.Kind == "repository": | 2662 | case errors.As(err, ¬Found) && 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: ¶ms}) | ||
| 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: ¶ms}) | ||
| 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: ¶ms}) | ||
| 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.coverage | 199 | 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 | +} | ||
| 201 | func (c *Collector) Close(ctx context.Context, workersUnwound bool) bool { | 218 | func (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 | +} | ||
| @@ -1,6 +1,7 @@ | |||
| 1 | package observability | 1 | package observability |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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 | } |
| 38 | type ExecutionHealth struct { | 40 | type 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. |
| 70 | func ProjectExecutionHealth(at time.Time, evidence []HealthEvidence) ExecutionHealth { | 72 | func 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 | continue | 92 | continue |
| @@ -111,12 +124,15 @@ func ProjectExecutionHealth(at time.Time, evidence []HealthEvidence) ExecutionHe | |||
| 111 | c.hasProblem = true | 124 | 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 | continue | 131 | continue |
| 116 | } | 132 | } |
| 117 | e := c.problem | 133 | 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 out | 179 | + if err := ctx.Err(); err != nil { |
| 180 | + return ExecutionHealth{}, err | ||
| 181 | + } | ||
| 182 | + return out, nil | ||
| 164 | } | 183 | } |
| 165 | 184 | ||
| 166 | func laterHealthTie(a, b HealthEvidence) bool { | 185 | func laterHealthTie(a, b HealthEvidence) bool { |
| @@ -237,5 +256,5 @@ func validHealthEvidence(e HealthEvidence) bool { | |||
| 237 | default: | 256 | default: |
| 238 | return false | 257 | 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 @@ | |||
| 1 | package observability | 1 | package observability |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | + "context" | ||
| 4 | "math" | 5 | "math" |
| 5 | "sort" | 6 | "sort" |
| 6 | "strings" | 7 | "strings" |
| @@ -405,6 +406,37 @@ func metricHistoryIndex(minute int64) int { | |||
| 405 | func (m *Metrics) Snapshot() MetricsSnapshot { | 406 | func (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 out | 482 | + 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.OutputState | 27 | 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.State | 31 | out.MetricsState = metrics.State |
| 32 | } | 32 | } |
| 33 | if !metrics.SampledAt.IsZero() { | 33 | if !metrics.SampledAt.IsZero() { |