diff --git a/agents/agents.go b/agents/agents.go index 7e12eaec..04211dc1 100644 --- a/agents/agents.go +++ b/agents/agents.go @@ -20,6 +20,7 @@ import ( codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0" providerv0 "github.com/codefly-dev/core/generated/go/codefly/services/provider/v0" runtimev0 "github.com/codefly-dev/core/generated/go/codefly/services/runtime/v0" + solutionv0 "github.com/codefly-dev/core/generated/go/codefly/services/solution/v0" toolboxv0 "github.com/codefly-dev/core/generated/go/codefly/services/toolbox/v0" toolingv0 "github.com/codefly-dev/core/generated/go/codefly/services/tooling/v0" "github.com/codefly-dev/core/grpcconfig" @@ -84,6 +85,7 @@ type PluginRegistration struct { Tooling toolingv0.ToolingServer // Transitional: collapses into Toolbox via lang.* convention. Toolbox toolboxv0.ToolboxServer // The unified callable contract (MCP-shape). Provider providerv0.ProviderServer // Provider-neutral external binding reconciliation. + Solution solutionv0.SolutionServer // Solution executor: scaffold/package/render a solution spec. ExecutionExporter executionv1.ExecutionExporterServer // Product-neutral receipt exporter plugin. // PDP gates Toolbox tool calls when non-nil. Wires the @@ -471,6 +473,9 @@ func Serve(reg PluginRegistration) { if reg.Provider != nil { providerv0.RegisterProviderServer(s, reg.Provider) } + if reg.Solution != nil { + solutionv0.RegisterSolutionServer(s, reg.Solution) + } if reg.Toolbox != nil { // Wrap with policyguard.Guard when a PDP is configured. The // Guard intercepts CallTool/ReadResource/GetPrompt and routes diff --git a/agents/serve_solution_test.go b/agents/serve_solution_test.go new file mode 100644 index 00000000..bb539bb3 --- /dev/null +++ b/agents/serve_solution_test.go @@ -0,0 +1,108 @@ +package agents + +import ( + "bufio" + "bytes" + "context" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + solutionv0 "github.com/codefly-dev/core/generated/go/codefly/services/solution/v0" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" +) + +// TestServeRegistersSolution spawns a real agent binary that registers a +// Solution server through PluginRegistration and confirms Serve exposes the +// contract: a Solution RPC over the wire returns the fixture's sentinel. +// Because the fixture implements GetSolutionInformation (rather than +// inheriting the embedded Unimplemented), a nil error + sentinel proves Serve +// registered the service and routed to the handler — deleting the Solution +// registration in Serve would surface here as an Unimplemented "unknown +// service" error, which the embedded default could not produce. +func TestServeRegistersSolution(t *testing.T) { + binary := filepath.Join(t.TempDir(), "solutionagent") + build := exec.Command("go", "build", "-o", binary, "./testdata/solutionagent") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build solution fixture: %v\n%s", err, output) + } + + const token = "test-token" + cmd := exec.Command(binary) + // Empty UDS path forces the TCP loopback handshake (dns:///127.0.0.1:PORT), + // sidestepping the OS sun_path length limit on long temp-dir socket paths. + cmd.Env = append(os.Environ(), "CODEFLY_AGENT_TOKEN="+token, "CODEFLY_AGENT_UDS_PATH=") + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + }) + + endpoint := readHandshakeEndpoint(t, stdout, &stderr) + + conn, err := grpc.NewClient(endpoint, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("dial %s: %v", endpoint, err) + } + t.Cleanup(func() { _ = conn.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + ctx = metadata.AppendToOutgoingContext(ctx, AuthMetadataKey, token) + + resp, err := solutionv0.NewSolutionClient(conn).GetSolutionInformation(ctx, &solutionv0.GetSolutionInformationRequest{}) + if err != nil { + t.Fatalf("GetSolutionInformation: %v\nagent stderr:\n%s", err, stderr.String()) + } + if resp.GetArtifact().GetName() != "solution-fixture" { + t.Fatalf("Solution RPC did not route to the registered handler: %+v", resp) + } +} + +// readHandshakeEndpoint reads the "VERSION|endpoint" line the agent writes to +// stdout on startup, verifies the protocol version, and returns the endpoint. +func readHandshakeEndpoint(t *testing.T, stdout io.Reader, stderr *bytes.Buffer) string { + t.Helper() + type result struct { + line string + err error + } + lines := make(chan result, 1) + go func() { + line, err := bufio.NewReader(stdout).ReadString('\n') + lines <- result{line: line, err: err} + }() + + select { + case r := <-lines: + if r.err != nil { + t.Fatalf("read handshake: %v\nagent stderr:\n%s", r.err, stderr.String()) + } + parts := strings.SplitN(strings.TrimSpace(r.line), "|", 2) + if len(parts) != 2 { + t.Fatalf("malformed handshake %q", r.line) + } + if version, err := strconv.Atoi(parts[0]); err != nil || version != ProtocolVersion { + t.Fatalf("handshake version %q != %d", parts[0], ProtocolVersion) + } + return parts[1] + case <-time.After(10 * time.Second): + t.Fatalf("agent did not emit handshake within 10s\nagent stderr:\n%s", stderr.String()) + return "" + } +} diff --git a/agents/testdata/solutionagent/main.go b/agents/testdata/solutionagent/main.go new file mode 100644 index 00000000..9efbcac4 --- /dev/null +++ b/agents/testdata/solutionagent/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "context" + + "github.com/codefly-dev/core/agents" + solutionv0 "github.com/codefly-dev/core/generated/go/codefly/services/solution/v0" +) + +// server implements a single Solution RPC returning a sentinel so the +// registration test can distinguish "Serve registered Solution and routed +// to this handler" from gRPC's Unimplemented (which the embedded +// UnimplementedSolutionServer would also return for an unregistered service). +type server struct { + solutionv0.UnimplementedSolutionServer +} + +func (server) GetSolutionInformation(context.Context, *solutionv0.GetSolutionInformationRequest) (*solutionv0.GetSolutionInformationResponse, error) { + return &solutionv0.GetSolutionInformationResponse{ + Artifact: &solutionv0.SolutionArtifact{Name: "solution-fixture"}, + }, nil +} + +func main() { + agents.Serve(agents.PluginRegistration{Solution: server{}}) +}