From ac8aa4c8687b1165794f3a70917ba9c015927af1 Mon Sep 17 00:00:00 2001 From: logikonline Date: Fri, 13 Feb 2026 01:44:55 -0500 Subject: [PATCH] feat(ai-service): implement plugin protocol for managed lifecycle Add gRPC-based plugin protocol implementation to the AI sidecar, enabling the GitCaddy server to manage it as an external plugin with lifecycle control and health monitoring. Plugin Protocol Implementation: - Add plugin.proto with PluginService definition (Initialize, Shutdown, HealthCheck, GetManifest, OnEvent, HandleHTTP) - Implement PluginServiceImpl gRPC service in C# - Return manifest declaring AI service capabilities, routes, and required permissions - Integrate license validation into health checks - Register plugin service alongside existing AI service Server Integration: - Configure Kestrel for HTTP/1.1 + HTTP/2 on port 5000 (enables gRPC + REST) - Map PluginService gRPC endpoint at /plugin.v1.PluginService - Enable server to call Initialize on startup, HealthCheck periodically, and Shutdown on graceful stop This completes Phase 5 of the AI integration, allowing the server's external plugin manager to monitor and control the sidecar's lifecycle instead of relying on manual process management. --- .notes/note-1770964636626-3yh3ujrlr.json | 8 ++ protos/plugin.proto | 98 ++++++++++++++ src/GitCaddy.AI.Service/Program.cs | 10 ++ .../Services/PluginServiceImpl.cs | 125 ++++++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 .notes/note-1770964636626-3yh3ujrlr.json create mode 100644 protos/plugin.proto create mode 100644 src/GitCaddy.AI.Service/Services/PluginServiceImpl.cs diff --git a/.notes/note-1770964636626-3yh3ujrlr.json b/.notes/note-1770964636626-3yh3ujrlr.json new file mode 100644 index 0000000..b912ebb --- /dev/null +++ b/.notes/note-1770964636626-3yh3ujrlr.json @@ -0,0 +1,8 @@ +{ + "id": "note-1770964636626-3yh3ujrlr", + "title": "Finish plugin", + "content": " Finish Deferred Items: Proto Codegen + Sidecar Plugin Migration\n\n Context\n\n The plugin framework (Phase 5) is built but has two deferred items that must be completed for\n production:\n\n 1. Proto codegen: pluginv1/types.go is hand-written Go structs mirroring plugin.proto. Should be\n generated code, matching how actions-proto-go works.\n 2. Sidecar as plugin: The AI sidecar needs to implement the plugin protocol so the server's external\n plugin manager can manage its lifecycle, health, and events.\n\n Architecture Decision\n\n Use gRPC for plugin transport (not raw JSON-over-HTTP):\n\n - The C# sidecar already has Grpc.AspNetCore, Grpc.Tools, Google.Protobuf, and auto-generates C#\n stubs from protos/*.proto\n - The Go server already uses Connect RPC (connectrpc.com/connect) for the runner service, which\n speaks gRPC protocol\n - Connect client with connect.WithGRPC() talks to standard gRPC servers over HTTP/2\n - This eliminates JSON serialization mismatches and gives full type safety on both sides\n - Kestrel already supports HTTP/2 (needed for gRPC); Go needs golang.org/x/net/http2 for h2c\n (cleartext HTTP/2)\n\n Path convention: /plugin.v1.PluginService/Initialize (standard gRPC/Connect)\n\n ---\n Task 1: Generate Go Protobuf + Connect Code\n\n 1a. Fix proto go_package for connect codegen\n\n File: modules/plugins/pluginv1/plugin.proto\n\n The go_package must include the package name for the connect subdirectory to work:\n option go_package = \"code.gitcaddy.com/server/v3/modules/plugins/pluginv1;pluginv1\";\n\n 1b. Generate code\n\n Run protoc (matching actions-proto-go toolchain: protoc-gen-go v1.28.1, protoc-gen-connect-go):\n\n protoc --go_out=. --go_opt=paths=source_relative \\\n --connect-go_out=. --connect-go_opt=paths=source_relative \\\n modules/plugins/pluginv1/plugin.proto\n\n This produces:\n - modules/plugins/pluginv1/plugin.pb.go — message types (replaces types.go)\n - modules/plugins/pluginv1/pluginv1connect/plugin.connect.go — Connect client + handler\n\n 1c. Delete hand-written types\n\n Delete: modules/plugins/pluginv1/types.go\n\n 1d. Add Makefile target\n\n File: Makefile — add target:\n .PHONY: generate-plugin-proto\n generate-plugin-proto:\n protoc --go_out=. --go_opt=paths=source_relative\n --connect-go_out=. --connect-go_opt=paths=source_relative\n modules/plugins/pluginv1/plugin.prot\n\n ---\n Task 2: Refactor ExternalPluginManager to Use Connect Client\n\n 2a. Replace manual HTTP/JSON with Connect client\n\n File: modules/plugins/external.go\n\n Replace httpClient *http.Client in ManagedPlugin with:\n client pluginv1connect.PluginServiceClient\n\n In StartAll(), when creating each ManagedPlugin, create the Connect client:\n import (\n \"connectrpc.com/connect\"\n \"golang.org/x/net/http2\"\n pluginv1connect \"code.gitcaddy.com/server/v3/modules/plugins/pluginv1/pluginv1connect\"\n )\n\n // h2c transport for cleartext HTTP/2 (gRPC without TLS)\n h2cClient := newH2CClient(cfg.HealthTimeout)\n mp.client = pluginv1connect.NewPluginServiceClient(h2cClient, address, connect.WithGRPC())\n\n Add helper for h2c client:\n func newH2CClient(timeout time.Duration) *http.Client {\n return &http.Client{\n Timeout: timeout,\n Transport: &http2.Transport{\n AllowHTTP: true,\n DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn,\n error) {\n var d net.Dialer\n return d.DialContext(ctx, network, addr)\n },\n },\n }\n }\n\n 2b. Update all RPC call sites\n\n Replace manual callRPC/callRPCWithContext with typed Connect calls:\n\n initializePlugin():\n resp, err := mp.client.Initialize(ctx, connect.NewRequest(&pluginv1.InitializeRequest{\n ServerVersion: \"3.0.0\",\n Config: map[string]string{},\n }))\n if err != nil { return err }\n mp.manifest = resp.Msg.Manifest\n\n shutdownPlugin():\n _, _ = mp.client.Shutdown(m.ctx, connect.NewRequest(&pluginv1.ShutdownRequest{Reason: \"server\n shutdown\"}))\n\n checkPlugin() in health.go:\n resp, err := mp.client.HealthCheck(healthCtx, connect.NewRequest(&pluginv1.HealthCheckRequest{}))\n\n callOnEvent():\n resp, err := mp.client.OnEvent(ctx, connect.NewRequest(event))\n\n callHandleHTTP():\n resp, err := mp.client.HandleHTTP(ctx, connect.NewRequest(req))\n\n 2c. Remove dead code\n\n Delete from external.go:\n - callRPC() method\n - callRPCWithContext() method\n - matchRoute() helper (rewrite route matching to work with manifest routes)\n - httpClient field from ManagedPlugin\n - \"bytes\", \"io\" imports (no longer needed)\n\n 2d. Update go.mod\n\n Add golang.org/x/net if not already present (for http2.Transport).\n\n ---\n Task 3: Add plugin.proto to C# Sidecar\n\n 3a. Copy proto file\n\n Copy: gitcaddy-server/modules/plugins/pluginv1/plugin.proto → gitcaddy-ai/protos/plugin.proto\n\n Add csharp_namespace option to the copy:\n option csharp_namespace = \"GitCaddy.AI.Plugin.Proto\";\n\n The existing .csproj already includes , so C# stubs will auto-generate on build.\n\n 3b. Verify build generates types\n\n Run dotnet build — should produce generated C# classes for:\n - InitializeRequest, InitializeResponse\n - ShutdownRequest, ShutdownResponse\n - HealthCheckRequest, HealthCheckResponse\n - PluginManifest, PluginRoute\n - PluginEvent, EventResponse\n - HTTPRequest, HTTPResponse\n - PluginService.PluginServiceBase (gRPC server base class)\n\n ---\n Task 4: Implement PluginService in C# Sidecar\n\n 4a. Create service implementation\n\n File: src/GitCaddy.AI.Service/Services/PluginServiceImpl.cs (NEW)\n\n using Grpc.Core;\n using GitCaddy.AI.Plugin.Proto;\n using GitCaddy.AI.Service.Licensing;\n using System.Reflection;\n\n namespace GitCaddy.AI.Service.Services;\n\n public class PluginServiceImpl : PluginService.PluginServiceBase\n {\n private readonly ILicenseValidator _licenseValidator;\n private readonly ILogger _logger;\n\n public override Task Initialize(InitializeRequest request, ServerCallContext\n context)\n {\n _logger.LogInformation(\"Plugin Initialize called by server v{Version}\",\n request.ServerVersion);\n return Task.FromResult(new InitializeResponse\n {\n Success = true,\n Manifest = BuildManifest()\n });\n }\n\n public override Task Shutdown(ShutdownRequest request, ServerCallContext\n context)\n {\n _logger.LogInformation(\"Plugin Shutdown requested: {Reason}\", request.Reason);\n // Trigger graceful shutdown\n return Task.FromResult(new ShutdownResponse { Success = true });\n }\n\n public override async Task HealthCheck(HealthCheckRequest request,\n ServerCallContext context)\n {\n var license = await _licenseValidator.ValidateAsync();\n var resp = new HealthCheckResponse\n {\n Healthy = license.IsValid,\n Status = license.IsValid ? \"operational\" : \"degraded\",\n };\n resp.Details.Add(\"version\", Assembly.GetExecutingAssembly().GetName().Version?.ToString() ??\n \"unknown\");\n resp.Details.Add(\"license_tier\", license.License?.Tier ?? \"none\");\n return resp;\n }\n\n public override Task GetManifest(GetManifestRequest request, ServerCallContext\n context)\n {\n return Task.FromResult(BuildManifest());\n }\n\n public override Task OnEvent(PluginEvent request, ServerCallContext context)\n {\n _logger.LogInformation(\"Plugin received event: {EventType} for repo {RepoId}\",\n request.EventType, request.RepoId);\n return Task.FromResult(new EventResponse { Handled = true });\n }\n\n public override Task HandleHTTP(HTTPRequest request, ServerCallContext context)\n {\n // Not used — sidecar is called directly via REST API, not proxied through plugin HTTP\n return Task.FromResult(new HTTPResponse { StatusCode = 501 });\n }\n\n private PluginManifest BuildManifest()\n {\n var manifest = new PluginManifest\n {\n Name = \"GitCaddy AI Service\",\n Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? \"1.0.0\",\n Description = \"AI-powered code intelligence service for GitCaddy\",\n LicenseTier = \"professional\",\n };\n manifest.SubscribedEvents.Add(\"license:updated\");\n manifest.RequiredPermissions.Add(\"ai:*\");\n // Declare REST API routes the sidecar handles\n manifest.Routes.AddRange(new[]\n {\n new PluginRoute { Method = \"POST\", Path = \"/api/v1/review/pull-request\", Description =\n \"Review a pull request\" },\n new PluginRoute { Method = \"POST\", Path = \"/api/v1/issues/triage\", Description = \"Triage\n an issue\" },\n new PluginRoute { Method = \"POST\", Path = \"/api/v1/issues/suggest-labels\", Description =\n \"Suggest labels\" },\n new PluginRoute { Method = \"POST\", Path = \"/api/v1/code/explain\", Description = \"Explain\n code\" },\n new PluginRoute { Method = \"POST\", Path = \"/api/v1/docs/generate\", Description =\n \"Generate documentation\" },\n new PluginRoute { Method = \"POST\", Path = \"/api/v1/docs/commit-message\", Description =\n \"Generate commit message\" },\n new PluginRoute { Method = \"POST\", Path = \"/api/v1/issues/respond\", Description =\n \"Generate issue response\" },\n new PluginRoute { Method = \"POST\", Path = \"/api/v1/workflows/inspect\", Description =\n \"Inspect workflow YAML\" },\n new PluginRoute { Method = \"GET\", Path = \"/api/v1/health\", Description = \"Health check\"\n },\n });\n return manifest;\n }\n }\n\n 4b. Register in Program.cs\n\n File: src/GitCaddy.AI.Service/Program.cs\n\n After app.MapGrpcService(), add:\n app.MapGrpcService();\n\n And register as singleton:\n builder.Services.AddSingleton();\n\n 4c. Ensure HTTP/2 support in Kestrel\n\n File: src/GitCaddy.AI.Service/Program.cs (or appsettings.json)\n\n Kestrel defaults to HTTP/1.1 + HTTP/2 on HTTPS. For cleartext (development/internal), add:\n builder.WebHost.ConfigureKestrel(options =>\n {\n options.ListenAnyIP(5000, listenOptions =>\n {\n listenOptions.Protocols =\n Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1AndHttp2;\n });\n });\n\n This enables gRPC (HTTP/2) alongside REST (HTTP/1.1) on the same port.\n\n ---\n Task 5: Server-side Plugin Config for AI Sidecar\n\n 5a. Example app.ini configuration\n\n Document in code comments and custom_conf example:\n [plugins.gitcaddy-ai]\n ENABLED = true\n ADDRESS = localhost:5000\n HEALTH_TIMEOUT = 5s\n SUBSCRIBED_EVENTS = license:updated\n\n No BINARY field — the sidecar runs externally (or via systemd/container orchestration).\n\n ---\n Task 6: Lint, Build, and Verify\n\n 6a. Go side\n\n - go build ./... — zero errors\n - golangci-lint run --timeout 5m ./modules/plugins/... — zero issues\n - gofmt -l modules/plugins/ — no output\n\n 6b. C# side\n\n - dotnet build in gitcaddy-ai — zero errors\n - Plugin proto generates C# stubs\n - PluginServiceImpl compiles against generated base class\n\n 6c. Integration check\n\n - Start sidecar\n - Configure [plugins.gitcaddy-ai] in server's app.ini\n - Server starts external plugin manager\n - Manager calls Initialize() via gRPC → gets manifest back\n - Health monitoring calls HealthCheck() every 30s → sidecar reports healthy\n - Server shutdown calls Shutdown() → sidecar logs the event\n\n ---\n Implementation Order\n ┌─────┬──────────────────────────────┬────────┬──────────────────────────────────────────┬──────────┐\n │ # │ Task │ Repo │ Files │ Depends │\n │ │ │ │ │ On │\n ├─────┼──────────────────────────────┼────────┼──────────────────────────────────────────┼──────────┤\n │ 1 │ Generate Go proto + connect │ server │ plugin.proto, plugin.pb.go, │ None │\n │ │ code │ │ plugin.connect.go, Makefile │ │\n ├─────┼──────────────────────────────┼────────┼──────────────────────────────────────────┼──────────┤\n │ 2 │ Refactor external.go to │ server │ external.go, health.go, go.mod │ Task 1 │\n │ │ Connect client │ │ │ │\n ├─────┼──────────────────────────────┼────────┼──────────────────────────────────────────┼──────────┤\n │ 3 │ Add plugin.proto to C# │ ai │ protos/plugin.proto, .csproj │ None │\n │ │ sidecar │ │ │ │\n ├─────┼──────────────────────────────┼────────┼──────────────────────────────────────────┼──────────┤\n │ 4 │ Implement PluginServiceImpl │ ai │ PluginServiceImpl.cs, Program.cs │ Task 3 │\n ├─────┼──────────────────────────────┼────────┼──────────────────────────────────────────┼──────────┤\n │ 5 │ Plugin config documentation │ server │ config.go (comments) │ None │\n ├─────┼──────────────────────────────┼────────┼──────────────────────────────────────────┼──────────┤\n │ 6 │ Lint + build + verify │ both │ all │ Tasks │\n │ │ │ │ │ 1-5 │\n └─────┴──────────────────────────────┴────────┴──────────────────────────────────────────┴──────────┘\n Parallelizable: Tasks 1-2 (Go) and Tasks 3-4 (C#) are independent.", + "createdAt": 1770964636624, + "updatedAt": 1770964640277, + "tags": [] +} \ No newline at end of file diff --git a/protos/plugin.proto b/protos/plugin.proto new file mode 100644 index 0000000..da1a97f --- /dev/null +++ b/protos/plugin.proto @@ -0,0 +1,98 @@ +syntax = "proto3"; + +package plugin.v1; + +option go_package = "code.gitcaddy.com/server/v3/modules/plugins/pluginv1;pluginv1"; +option csharp_namespace = "GitCaddy.AI.Plugin.Proto"; + +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +// PluginService is the RPC interface that external plugins must implement. +// The server calls these methods to manage the plugin's lifecycle and dispatch events. +service PluginService { + // Initialize is called when the server starts or the plugin is loaded + rpc Initialize(InitializeRequest) returns (InitializeResponse); + // Shutdown is called when the server is shutting down + rpc Shutdown(ShutdownRequest) returns (ShutdownResponse); + // HealthCheck checks if the plugin is healthy + rpc HealthCheck(PluginHealthCheckRequest) returns (PluginHealthCheckResponse); + // GetManifest returns the plugin's manifest describing its capabilities + rpc GetManifest(GetManifestRequest) returns (PluginManifest); + // OnEvent is called when an event the plugin is subscribed to occurs + rpc OnEvent(PluginEvent) returns (EventResponse); + // HandleHTTP proxies an HTTP request to the plugin + rpc HandleHTTP(HTTPRequest) returns (HTTPResponse); +} + +message InitializeRequest { + string server_version = 1; + map config = 2; +} + +message InitializeResponse { + bool success = 1; + string error = 2; + PluginManifest manifest = 3; +} + +message ShutdownRequest { + string reason = 1; +} + +message ShutdownResponse { + bool success = 1; +} + +message PluginHealthCheckRequest {} + +message PluginHealthCheckResponse { + bool healthy = 1; + string status = 2; + map details = 3; +} + +message GetManifestRequest {} + +message PluginManifest { + string name = 1; + string version = 2; + string description = 3; + repeated string subscribed_events = 4; + repeated PluginRoute routes = 5; + repeated string required_permissions = 6; + string license_tier = 7; +} + +message PluginRoute { + string method = 1; + string path = 2; + string description = 3; +} + +message PluginEvent { + string event_type = 1; + google.protobuf.Struct payload = 2; + google.protobuf.Timestamp timestamp = 3; + int64 repo_id = 4; + int64 org_id = 5; +} + +message EventResponse { + bool handled = 1; + string error = 2; +} + +message HTTPRequest { + string method = 1; + string path = 2; + map headers = 3; + bytes body = 4; + map query_params = 5; +} + +message HTTPResponse { + int32 status_code = 1; + map headers = 2; + bytes body = 3; +} diff --git a/src/GitCaddy.AI.Service/Program.cs b/src/GitCaddy.AI.Service/Program.cs index d0b918f..b41ffcf 100644 --- a/src/GitCaddy.AI.Service/Program.cs +++ b/src/GitCaddy.AI.Service/Program.cs @@ -32,6 +32,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Add gRPC builder.Services.AddGrpc(options => @@ -48,6 +49,14 @@ builder.Services.AddEndpointsApiExplorer(); // Add health checks builder.Services.AddHealthChecks(); +builder.WebHost.ConfigureKestrel(options => +{ + options.ListenAnyIP(5000, listenOptions => + { + listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1AndHttp2; + }); +}); + var app = builder.Build(); // Validate license on startup @@ -65,6 +74,7 @@ else // Map gRPC services app.MapGrpcService(); +app.MapGrpcService(); app.MapHealthChecks("/healthz"); // Map REST API controllers diff --git a/src/GitCaddy.AI.Service/Services/PluginServiceImpl.cs b/src/GitCaddy.AI.Service/Services/PluginServiceImpl.cs new file mode 100644 index 0000000..7f3b24a --- /dev/null +++ b/src/GitCaddy.AI.Service/Services/PluginServiceImpl.cs @@ -0,0 +1,125 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +using Grpc.Core; +using GitCaddy.AI.Plugin.Proto; +using GitCaddy.AI.Service.Licensing; + +namespace GitCaddy.AI.Service.Services; + +/// +/// gRPC service implementation for the GitCaddy plugin protocol. +/// Allows the GitCaddy server to manage this sidecar as a plugin. +/// +public class PluginServiceImpl : PluginService.PluginServiceBase +{ + private readonly ILogger _logger; + private readonly ILicenseValidator _licenseValidator; + + public PluginServiceImpl( + ILicenseValidator licenseValidator, + ILogger logger) + { + _licenseValidator = licenseValidator; + _logger = logger; + } + + public override Task Initialize( + InitializeRequest request, ServerCallContext context) + { + _logger.LogInformation("Plugin initialized by server version {ServerVersion}", request.ServerVersion); + + return Task.FromResult(new InitializeResponse + { + Success = true, + Manifest = BuildManifest() + }); + } + + public override Task Shutdown( + ShutdownRequest request, ServerCallContext context) + { + _logger.LogInformation("Plugin shutdown requested: {Reason}", request.Reason); + + return Task.FromResult(new ShutdownResponse + { + Success = true + }); + } + + public override async Task HealthCheck( + PluginHealthCheckRequest request, ServerCallContext context) + { + var licenseResult = await _licenseValidator.ValidateAsync(); + var version = typeof(PluginServiceImpl).Assembly.GetName().Version?.ToString() ?? "1.0.0"; + + var response = new PluginHealthCheckResponse + { + Healthy = licenseResult.IsValid, + Status = licenseResult.IsValid ? "healthy" : "degraded" + }; + + response.Details["version"] = version; + response.Details["license_tier"] = licenseResult.License?.Tier ?? "none"; + + return response; + } + + public override Task GetManifest( + GetManifestRequest request, ServerCallContext context) + { + return Task.FromResult(BuildManifest()); + } + + public override Task OnEvent( + PluginEvent request, ServerCallContext context) + { + _logger.LogInformation("Received event {EventType} for repo {RepoId}", request.EventType, request.RepoId); + + return Task.FromResult(new EventResponse + { + Handled = true + }); + } + + public override Task HandleHTTP( + HTTPRequest request, ServerCallContext context) + { + // Not used — the sidecar is called directly via REST by the server. + return Task.FromResult(new HTTPResponse + { + StatusCode = 501 + }); + } + + private static PluginManifest BuildManifest() + { + var version = typeof(PluginServiceImpl).Assembly.GetName().Version?.ToString() ?? "1.0.0"; + + var manifest = new PluginManifest + { + Name = "GitCaddy AI Service", + Version = version, + Description = "AI-powered code intelligence service for GitCaddy", + LicenseTier = "professional" + }; + + manifest.SubscribedEvents.Add("license:updated"); + manifest.RequiredPermissions.Add("ai:*"); + + manifest.Routes.AddRange(new[] + { + new PluginRoute { Method = "POST", Path = "/api/v1/review/pull-request", Description = "Review a pull request" }, + new PluginRoute { Method = "POST", Path = "/api/v1/issues/triage", Description = "Triage an issue" }, + new PluginRoute { Method = "POST", Path = "/api/v1/issues/suggest-labels", Description = "Suggest labels for an issue" }, + new PluginRoute { Method = "POST", Path = "/api/v1/code/explain", Description = "Explain code" }, + new PluginRoute { Method = "POST", Path = "/api/v1/docs/generate", Description = "Generate documentation" }, + new PluginRoute { Method = "POST", Path = "/api/v1/docs/commit-message", Description = "Generate commit message" }, + new PluginRoute { Method = "POST", Path = "/api/v1/issues/respond", Description = "Generate a response to an issue" }, + new PluginRoute { Method = "POST", Path = "/api/v1/workflows/inspect", Description = "Inspect a workflow YAML file" }, + new PluginRoute { Method = "GET", Path = "/api/v1/health", Description = "Health check" } + }); + + return manifest; + } +}