diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index d77d617bc..c6e98fe4f 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -5198,6 +5198,14 @@ single-resource discovery, and `pulse_discovery` refreshes before any `discovery_interval_hours: 0` is the only manual-command-scan mode: recurring scans stay stopped, but explicit admin-triggered refreshes may use the hardcoded trusted catalog. +Each per-organization AI service now passes its organization-pinned +`AgentServer` interface directly into `internal/ai/discovery_adapter.go`. +Deep discovery does not recover the concrete global `agentexec.Server` or +silently drop command execution when the runtime supplies an +`agentexec.OrganizationServer`; connected-agent lookup and command dispatch +therefore remain on the same tenant-scoped view. The interface-backed service +regression in `internal/ai/service_test.go` requires a real deep-discovery run +to dispatch the trusted probe catalog and retain its command evidence. The value boundary for keeping Discovery is observed workload context: Assistant and Patrol may consume normalized service name, version, endpoint, port, config path, data path, log path, bind-mount, confidence, and user-note diff --git a/internal/ai/discovery_adapter.go b/internal/ai/discovery_adapter.go index 6ced12230..f0d890c7a 100644 --- a/internal/ai/discovery_adapter.go +++ b/internal/ai/discovery_adapter.go @@ -15,13 +15,14 @@ const ( minDiscoveryCommandTimeoutSeconds = 1 ) -// discoveryCommandAdapter adapts agentexec.Server to servicediscovery.CommandExecutor +// discoveryCommandAdapter adapts the AI service's organization-scoped agent +// server view to servicediscovery.CommandExecutor. type discoveryCommandAdapter struct { - server *agentexec.Server + server AgentServer } // newDiscoveryCommandAdapter creates a new adapter -func newDiscoveryCommandAdapter(server *agentexec.Server) *discoveryCommandAdapter { +func newDiscoveryCommandAdapter(server AgentServer) *discoveryCommandAdapter { return &discoveryCommandAdapter{server: server} } @@ -97,7 +98,12 @@ func (a *discoveryCommandAdapter) IsAgentConnected(agentID string) bool { if a.server == nil { return false } - return a.server.IsAgentConnected(agentID) + for _, agent := range a.server.GetConnectedAgents() { + if agent.AgentID == agentID { + return true + } + } + return false } func nonNilContext(ctx context.Context) context.Context { diff --git a/internal/ai/service.go b/internal/ai/service.go index 9165388b6..16d08707a 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -608,10 +608,10 @@ func (s *Service) initDiscoveryServiceLocked() { return } - // Create command executor adapter (wraps agentexec.Server) + // Create a command executor adapter over the organization-scoped server view. var cmdExecutor servicediscovery.CommandExecutor - if agentSrv, ok := s.agentServer.(*agentexec.Server); ok { - cmdExecutor = newDiscoveryCommandAdapter(agentSrv) + if s.agentServer != nil { + cmdExecutor = newDiscoveryCommandAdapter(s.agentServer) } // Create deep scanner diff --git a/internal/ai/service_test.go b/internal/ai/service_test.go index fc5b58912..33d78b474 100644 --- a/internal/ai/service_test.go +++ b/internal/ai/service_test.go @@ -6,13 +6,16 @@ import ( "errors" "os" "strings" + "sync/atomic" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" + "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/servicediscovery" unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts" ) @@ -353,6 +356,54 @@ func TestService_LoadConfig_SyncsInfraDiscoveryLifecycle(t *testing.T) { } } +func TestService_DeepDiscoveryUsesInterfaceBackedAgentServer(t *testing.T) { + var commandCalls atomic.Int32 + agentServer := &mockAgentServer{ + agents: []agentexec.ConnectedAgent{{ + AgentID: "agent-1", + Hostname: "home-assistant", + Platform: "linux", + }}, + executeFunc: func(_ context.Context, _ string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) { + commandCalls.Add(1) + return &agentexec.CommandResultPayload{ + RequestID: cmd.RequestID, + Success: true, + Stdout: "probe output", + }, nil + }, + } + + svc := NewService(config.NewConfigPersistence(t.TempDir()), agentServer) + defer svc.Stop() + registry := unifiedresources.NewRegistry(nil) + registry.IngestSnapshot(models.StateSnapshot{}) + svc.SetReadState(registry) + + discoveryService := svc.GetDiscoveryService() + if discoveryService == nil { + t.Fatal("expected deep discovery service to be initialized") + } + discoveryService.SetCommandScanningEnabled(true) + + result, err := discoveryService.DiscoverResource(context.Background(), servicediscovery.DiscoveryRequest{ + ResourceType: servicediscovery.ResourceTypeAgent, + ResourceID: "agent-1", + TargetID: "agent-1", + Hostname: "home-assistant", + Force: true, + }) + if err != nil { + t.Fatalf("DiscoverResource() error = %v", err) + } + if commandCalls.Load() == 0 { + t.Fatal("expected deep discovery to dispatch commands through the interface-backed agent server") + } + if len(result.RawCommandOutput) == 0 { + t.Fatal("expected deep discovery to retain command evidence") + } +} + func TestService_GetCostSummary_NoStore(t *testing.T) { svc := NewService(nil, nil) svc.costStore = nil