diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 47423dac1..32c176eea 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -7599,3 +7599,13 @@ evidence for at least one plausible causal peer or dependency. Later topology/query/discovery resource entries take precedence over an earlier empty Docker Swarm-service result when determining whether a container peer is implicated. + +Resource-scoped investigation health evidence uses the canonical resource +projection. `pulse_query` with `action=health` and a non-empty `resource_id` +must resolve through the same governed lookup as `action=get`, including the +canonical identity, lifecycle status, provider health, restart count, and +health-check dependency targets available for an app container. It must not +silently ignore the resource selector and return fleet connection counts. +Calls without `resource_id` retain the existing aggregate connection-health +overview for backward compatibility. Provider-native IDs and names remain +lookup aliases; responses preserve the canonical unified-resource ID. diff --git a/internal/ai/tools/data_types.go b/internal/ai/tools/data_types.go index 3458945ee..594b2eb28 100644 --- a/internal/ai/tools/data_types.go +++ b/internal/ai/tools/data_types.go @@ -662,27 +662,28 @@ type TopologySummary struct { // ResourceResponse is returned by pulse_get_resource type ResourceResponse struct { GovernedResourceMetadata - Type string `json:"type"` // "agent", "vm", "system-container", "app-container", "docker-host", "storage" - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - Platform string `json:"platform,omitempty"` - Node string `json:"node,omitempty"` - Host string `json:"host,omitempty"` - CPU ResourceCPU `json:"cpu"` - Memory ResourceMemory `json:"memory"` - Disk *ResourceDisk `json:"disk,omitempty"` - OS string `json:"os,omitempty"` - Tags []string `json:"tags"` - Networks []NetworkInfo `json:"networks"` - Ports []PortInfo `json:"ports"` - Mounts []MountInfo `json:"mounts"` - Labels map[string]string `json:"labels"` - LastBackup *time.Time `json:"last_backup,omitempty"` - Image string `json:"image,omitempty"` - Health string `json:"health,omitempty"` - RestartCount int `json:"restart_count,omitempty"` - UpdateAvailable bool `json:"update_available,omitempty"` + Type string `json:"type"` // "agent", "vm", "system-container", "app-container", "docker-host", "storage" + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Platform string `json:"platform,omitempty"` + Node string `json:"node,omitempty"` + Host string `json:"host,omitempty"` + CPU ResourceCPU `json:"cpu"` + Memory ResourceMemory `json:"memory"` + Disk *ResourceDisk `json:"disk,omitempty"` + OS string `json:"os,omitempty"` + Tags []string `json:"tags"` + Networks []NetworkInfo `json:"networks"` + Ports []PortInfo `json:"ports"` + Mounts []MountInfo `json:"mounts"` + Labels map[string]string `json:"labels"` + LastBackup *time.Time `json:"last_backup,omitempty"` + Image string `json:"image,omitempty"` + Health string `json:"health,omitempty"` + HealthcheckTargets []string `json:"healthcheck_targets"` + RestartCount int `json:"restart_count,omitempty"` + UpdateAvailable bool `json:"update_available,omitempty"` } func EmptyResourceResponse() ResourceResponse { @@ -705,6 +706,9 @@ func (r ResourceResponse) NormalizeCollections() ResourceResponse { if r.Labels == nil { r.Labels = map[string]string{} } + if r.HealthcheckTargets == nil { + r.HealthcheckTargets = []string{} + } for i := range r.Networks { r.Networks[i] = r.Networks[i].NormalizeCollections() } diff --git a/internal/ai/tools/infrastructure_low_coverage_test.go b/internal/ai/tools/infrastructure_low_coverage_test.go index 937f74ca9..f51e9b8dd 100644 --- a/internal/ai/tools/infrastructure_low_coverage_test.go +++ b/internal/ai/tools/infrastructure_low_coverage_test.go @@ -326,6 +326,11 @@ func TestExecuteGetConnectionHealth(t *testing.T) { assert.Equal(t, 2, resp.Total) assert.Equal(t, 1, resp.Connected) assert.Equal(t, 1, resp.Disconnected) + + result, err = exec.executeQuery(ctx, map[string]interface{}{"action": "health"}) + require.NoError(t, err) + require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp)) + assert.Equal(t, 2, resp.Total, "health without resource_id must retain the connection overview contract") } func TestConnectionHealthResponseUsesCanonicalEmptyCollections(t *testing.T) { diff --git a/internal/ai/tools/tools_query.go b/internal/ai/tools/tools_query.go index 9e49f2ae6..290991832 100644 --- a/internal/ai/tools/tools_query.go +++ b/internal/ai/tools/tools_query.go @@ -2151,7 +2151,7 @@ func (e *PulseToolExecutor) registerQueryTools() { e.registry.registerBuiltin(RegisteredTool{ Definition: Tool{ Name: agentcapabilities.PulseQueryToolName, - Description: `Query and search canonical infrastructure resources. Start here to discover systems, workloads, storage, and disks by name. Actions: search, get, config, topology, list, health.`, + Description: `Query and search canonical infrastructure resources. Start here to discover systems, workloads, storage, and disks by name. Actions: search, get, config, topology, list, health. Health returns the connection overview by default, or the canonical resource projection when resource_id is provided.`, InputSchema: InputSchema{ Type: "object", Properties: map[string]PropertySchema{ @@ -2171,7 +2171,7 @@ func (e *PulseToolExecutor) registerQueryTools() { }, "resource_id": { Type: "string", - Description: "Canonical resource identifier (for action: get, config). Provider-native IDs and names are accepted as lookup aliases, but canonical unified resources are returned with their canonical ID.", + Description: "Canonical resource identifier (for action: get, config, or resource-scoped health). Provider-native IDs and names are accepted as lookup aliases, but canonical unified resources are returned with their canonical ID.", }, "type": { Type: "string", @@ -3612,12 +3612,25 @@ func (e *PulseToolExecutor) executeQuery(ctx context.Context, args map[string]in case "list": return e.executeListInfrastructure(ctx, args) case "health": - return e.executeGetConnectionHealth(ctx, args) + return e.executeGetHealth(ctx, args) default: return NewErrorResult(fmt.Errorf("unknown action: %s. Use: search, get, config, topology, list, health", action)), nil } } +// executeGetHealth preserves the fleet connection-health overview for callers +// that do not select a resource. Once a resource_id is present, health must be +// resolved through the same canonical, governed projection as action=get. This +// prevents a resource-scoped investigation from receiving unrelated aggregate +// connection counts while retaining backward compatibility for overview calls. +func (e *PulseToolExecutor) executeGetHealth(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { + resourceID, _ := args["resource_id"].(string) + if strings.TrimSpace(resourceID) != "" { + return e.executeGetResource(ctx, args) + } + return e.executeGetConnectionHealth(ctx, args) +} + func (e *PulseToolExecutor) executeListInfrastructure(_ context.Context, args map[string]interface{}) (CallToolResult, error) { rs, err := e.readStateForControl() if err != nil { @@ -4971,6 +4984,7 @@ func (e *PulseToolExecutor) executeGetResource(_ context.Context, args map[strin if resource.Docker != nil { response.Image = strings.TrimSpace(resource.Docker.Image) response.Health = strings.TrimSpace(resource.Docker.Health) + response.HealthcheckTargets = append([]string{}, resource.Docker.HealthcheckTargets...) response.RestartCount = resource.Docker.RestartCount response.Labels = resource.Docker.Labels if update := resource.Docker.UpdateStatus; update != nil && update.UpdateAvailable { @@ -5065,6 +5079,7 @@ func (e *PulseToolExecutor) executeGetResource(_ context.Context, args map[strin response.Host = hostName response.Image = container.Image() response.Health = container.Health() + response.HealthcheckTargets = container.HealthcheckTargets() response.CPU = ResourceCPU{ Percent: container.CPUPercent(), } diff --git a/internal/ai/tools/tools_query_test.go b/internal/ai/tools/tools_query_test.go index df960d0e0..32307f78a 100644 --- a/internal/ai/tools/tools_query_test.go +++ b/internal/ai/tools/tools_query_test.go @@ -1215,6 +1215,66 @@ func TestExecuteQuery_AppContainerPreservesCanonicalResourceIDAcrossAliases(t *t } } +func TestExecuteQuery_HealthScopesToCanonicalResourceWhenRequested(t *testing.T) { + const canonicalID = "app-container-97ba2f28c480ed82" + provider := &stubUnifiedResourceProvider{resources: []unifiedresources.Resource{{ + ID: canonicalID, + Type: unifiedresources.ResourceTypeAppContainer, + Technology: "docker", + Name: "database", + Status: unifiedresources.StatusOffline, + ParentName: "colima", + Docker: &unifiedresources.DockerData{ + ContainerID: strings.Repeat("9", 64), + ContainerState: "exited", + Health: "unhealthy", + HealthcheckTargets: []string{"database.internal"}, + RestartCount: 3, + }, + }}} + executor := NewPulseToolExecutor(ExecutorConfig{ + StateProvider: &mockStateProvider{state: models.StateSnapshot{}}, + UnifiedResourceProvider: provider, + }) + + result, err := executor.executeQuery(context.Background(), map[string]interface{}{ + "action": "health", + "resource_id": canonicalID, + }) + if err != nil { + t.Fatalf("resource-scoped health: %v", err) + } + var response ResourceResponse + if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil { + t.Fatalf("decode resource-scoped health response: %v", err) + } + if response.ID != canonicalID || response.Type != "app-container" || response.Name != "database" { + t.Fatalf("resource identity was not preserved: %+v", response) + } + if response.Status != "exited" || response.Health != "unhealthy" || response.RestartCount != 3 { + t.Fatalf("resource health facts were not preserved: %+v", response) + } + if len(response.HealthcheckTargets) != 1 || response.HealthcheckTargets[0] != "database.internal" { + t.Fatalf("health-check dependency targets were not preserved: %+v", response.HealthcheckTargets) + } + + result, err = executor.executeQuery(context.Background(), map[string]interface{}{ + "action": "health", + "resource_type": "app-container", + "resource_id": "missing", + }) + if err != nil { + t.Fatalf("unknown resource health: %v", err) + } + var notFound map[string]interface{} + if err := json.Unmarshal([]byte(result.Content[0].Text), ¬Found); err != nil { + t.Fatalf("decode unknown resource health response: %v", err) + } + if notFound["error"] != "not_found" || notFound["resource_id"] != "missing" { + t.Fatalf("unexpected unknown resource response: %+v", notFound) + } +} + func TestExecuteGetResource_RegistersTrueNASAppContainerForCanonicalControl(t *testing.T) { provider := newTrueNASUnifiedQueryProvider(t) resolved := &mockResolvedContext{