Let agents read a resource's metric history

Agents could read current values and, since list_recent_changes, what
changed, but not how a resource's metrics moved: whether a disk filled
gradually or suddenly, whether memory climbed before a restart. Pulse
records that history for its own charts.

get_metric_history (GET /api/agent/metric-history, monitoring:read,
on the Pulse MCP surface) takes the canonical resource id the other
agent reads return, resolves the resource's metrics target through the
registry, and delegates to the chart history read. Agents and charts
therefore share tiers, fallbacks, downsampling and the licensed history
limit: a window beyond it returns the same license_required error.

The history route now accepts the registry's Kubernetes target kinds
(pod, k8s-cluster, k8s-node, k8s-deployment) as the k8s store type.
The frontend translated them client-side; any other caller passing a
target through got a 400.
This commit is contained in:
rcourtman
2026-10-29 09:20:32 +00:00
parent 60d0797129
commit f458de49e8
17 changed files with 371 additions and 11 deletions
+1
View File
@@ -275,6 +275,7 @@ the live set.
- `list_resource_capabilities` (List resource capabilities, `GET /api/agent/resource-capabilities/{resourceId}`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the structured governed capabilities a resource advertises (name, type, approval level, platform, and full parameter schemas). Companion to get_resource_context, which renders capabilities as count-limited prose; this is the structured surface an agent reads before calling plan_action so it can populate capabilityName and params without guessing. A resource with no advertised capabilities returns an empty array.
- `get_fleet_context` (Get fleet context, `GET /api/agent/fleet-context`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return a thin per-resource triage rollup across every resource visible to the org — identity, operator flags (intentionallyOffline, neverAutoRemediate, maintenanceWindowActive), per-severity finding counts (total/critical/warning/info), and pending-approval count. One read for 'where do I focus?'; follow up via get_resource_context for depth. Optional additive filters (hasFindings, severity, technology, resourceType) narrow the result to a relevant subset so agents triaging a large fleet do not receive or page through healthy resources.
- `list_recent_changes` (List recent changes, `GET /api/agent/changes`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return what Pulse observed changing across the fleet, newest first: state transitions, restarts, configuration and relationship changes, alerts fired, acknowledged, snoozed and resolved, and commands or runbooks run. One read for 'what happened since last night?'; optional filters (since, before, resourceId, kind, sourceType) narrow it. Only changes Pulse observed are listed, so a gap is not proof that nothing happened. Results are capped, and truncated says when more matched. Command text, output and runbook messages are redacted for monitoring-read API tokens unless the token also has ai:execute.
- `get_metric_history` (Get metric history, `GET /api/agent/metric-history`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return how one resource's metrics moved over a window, from the history Pulse records while it monitors: 'did this disk fill gradually or suddenly?', 'was memory climbing before the restart?'. Pass the canonical resourceId from get_fleet_context, list_recent_changes or get_resource_context. Omit metric to get every metric the resource records, which also tells you their names. Points are averaged into about maxPoints buckets (window edges can add one), each with min and max. Windows longer than the install's licensed history (7 days on Community) return a license_required error.
- `get_patrol_control_status` (Get Patrol work status, `GET /api/agent/patrol-control/status`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the current content-safe Patrol work status: Patrol issue evidence, pending approvals, governed decisions/actions, verified outcomes, Patrol control outcome evidence, compatibility aliases, and optional MCP readiness. The payload is count-only and deliberately omits finding ids, action ids, prompts, commands, resource names, and output.
**Operator state (per-resource intent):**
@@ -2749,6 +2749,9 @@ agents may read what changed across the fleet, but `command_executed` and
`commandRedacted:true`, the command text, output, and caller details dropped,
and a command's reason cut to its status. A change record is history, never command
disclosure, install-token scope, or execution authority.
Metric history at `/api/agent/metric-history` (`get_metric_history`) is the
same read-only boundary: a resource's recorded metric series for a window,
never install, enrollment or command authority.
The `alert.fired`, `alert.resolved` and `resource.status_changed` events on
`/api/agent/events` are the push form of the same records and the same
@@ -2086,6 +2086,17 @@ changes: it does not reconstruct events Pulse did not see, and command, output
and runbook message content is redacted for tokens without `ai:execute`. Integer
filters reach the route because the shared projection forwards integral JSON
numbers as query parameters.
`get_metric_history` is the manifest-owned metric-history read for external
agents (`GET /api/agent/metric-history`, `monitoring:read`). It takes the
canonical resource id the other agent reads return, resolves the resource's
`MetricsTarget` through the registry, and delegates to the chart history read
(`handleMetricsHistory`), so an agent and the history charts share tiers,
fallbacks, downsampling and the licensed history limit; a window beyond that
limit returns the same `license_required` error. It counts as resource-context
external-agent activity rather than a new telemetry activity. A range outside
`MetricHistoryRanges` or a non-positive `maxPoints` is a 400. `maxPoints` (capped
at 200) is the downsampling target, not an output bound: short windows and
fallback reads can return native samples, as the chart route does.
than in `cmd/pulse-mcp` or an MCP-only inventory registry. They must enter
through `ManifestSurfaceResourceCapabilities`,
`ListMCPManifestSurfaceResourcesHTTP`, and
@@ -6802,6 +6802,27 @@ projection layer forwards integral JSON numbers as integer query
parameters so manifest integer filters such as `limit` reach the
route; fractional or out-of-range numbers are dropped like objects.
`/api/agent/metric-history` (`get_metric_history`, scope
`monitoring:read`) is the agent-consumable "how did it move?" read for
one resource. It takes the canonical `resourceId` (required), an
optional `metric` (omitted returns every recorded metric keyed by
name), `range` from `MetricHistoryRanges` (default 24h) and
`maxPoints` (default 60, capped at 200; a downsampling target, not an
output bound, so short windows and fallback reads can return native
samples). It resolves the resource's `MetricsTarget` through the
registry (`ResourceRegistry.MetricsTarget`, as the resources API does)
and delegates to
`handleMetricsHistory`, so its payload, tiers, fallbacks and licensed
history limit are exactly the chart history route's: a window beyond
the install's licensed history returns 402 `license_required`. An
unknown resource, or one without a `MetricsTarget`, is a 404 with a
reason; a missing `resourceId`, unknown range or non-positive
`maxPoints` is a 400. The response's `resourceId` is the metrics-store
id, which can differ from the canonical id requested.
`normalizeMetricsHistoryResourceType` accepts the registry's Kubernetes
target kinds (`pod`, `k8s-cluster`, `k8s-node`, `k8s-deployment`) as the
`k8s` store type, so a target passes through unchanged for every caller.
`/api/agent/resource-capabilities/{id}` is the agent-consumable
structured capability surface for a single resource: the governed
capabilities it advertises (name, type, approval level, platform, and
@@ -3770,3 +3770,18 @@ command metadata, so a monitoring-read token learns nothing from an event that
it could not already read through `list_recent_changes`. Proof:
`TestContract_AgentChangeEventsStayInsideTheirOrganization` in
`internal/api/contract_test.go`.
### Agent metric history reuses the chart route's authority (29 October 2026)
`/api/agent/metric-history` (`get_metric_history`) requires `monitoring:read`,
the same scope as `/api/metrics-store/history`. It resolves the canonical
resource id through the request organization's registry and hands a cloned
request, with the organization context intact, to `handleMetricsHistory`, so
tenant resolution, the licensed history limit and the response are the chart
route's own. The rewritten query carries only resourceType, resourceId, range,
maxPoints and metric; any other parameter the caller sent is dropped. A
monitoring-read token therefore reads nothing through it that it could not
already read from the chart route. `normalizeMetricsHistoryResourceType` now
also accepts the registry's Kubernetes target kinds (`pod`, `k8s-cluster`,
`k8s-node`, `k8s-deployment`) as the `k8s` store type; that widens which
spellings reach the same store type, not who may read it.
@@ -4731,6 +4731,11 @@ readable without a name, so the timeline's recovery posture is exactly the
`resource_changes` table's: whatever survives a restart or restore is what
agents read, with no timeline-specific rehydration or recovery artifact.
Metric history at `/api/agent/metric-history` (`get_metric_history`) adds no
persistence either. It resolves a resource's existing `MetricsTarget` and reads
through the same history path the charts use (the metrics store, then the
in-memory and live fallbacks), so its recovery posture is the metrics store's.
The live `alert.fired`, `alert.resolved` and `resource.status_changed` agent
events add no persistence. They are published in memory after the monitor
adapter has recorded the matching `resource_changes` row, and per-resource
+53
View File
@@ -108,6 +108,44 @@ func recentChangesOutputSchema() json.RawMessage {
})
}
// MetricHistoryRanges are the windows get_metric_history accepts. They match
// the history chart ranges, so an agent and the drawer read the same tiers.
var MetricHistoryRanges = []string{"30m", "1h", "6h", "12h", "24h", "7d", "30d", "90d"}
const (
MetricHistoryDefaultRange = "24h"
MetricHistoryDefaultMaxPoints = 60
MetricHistoryMaxPoints = 200
)
func metricHistoryInputSchema() json.RawMessage {
return agentObjectInputSchema([]string{ResourceIDArgumentName}, map[string]any{
ResourceIDArgumentName: stringOption("Canonical resource id from get_fleet_context, list_recent_changes or get_resource_context."),
"metric": stringOption("One recorded metric, such as cpu, memory, disk, netin, netout, diskread or diskwrite (storage records usage, used, avail and total). Omit to get every metric the resource records."),
"range": stringEnumOption(MetricHistoryRanges, "Window ending now. Defaults to "+MetricHistoryDefaultRange+". Windows beyond the install's licensed history return license_required."),
"maxPoints": map[string]any{
"type": "integer",
"minimum": 1,
"maximum": MetricHistoryMaxPoints,
"description": fmt.Sprintf("Target number of points per metric; window edges can add one. Defaults to %d; values above %d are capped. Short windows may return native samples instead.", MetricHistoryDefaultMaxPoints, MetricHistoryMaxPoints),
},
})
}
func metricHistoryOutputSchema() json.RawMessage {
return agentObjectOutputSchema([]string{"resourceType", "resourceId", "range", "start", "end", "source"}, map[string]any{
"resourceType": stringOption("Metrics-store type of the resource."),
"resourceId": stringOption("Metrics-store id of the resource, which can differ from the canonical id that was requested."),
"metric": stringOption("The requested metric, when one was given."),
"range": stringOption("Effective window."),
"start": integerOption("Window start, Unix milliseconds."),
"end": integerOption("Window end, Unix milliseconds."),
"points": objectArrayOption("Series for the requested metric, oldest first: timestamp (Unix milliseconds), value (bucket average), min and max."),
"metrics": map[string]any{"type": "object", "description": "Every recorded metric when none was requested, keyed by metric name, each an array of points shaped like points."},
"source": stringOption("Where the points came from: store (persistent history), memory (recent in-memory history while the store fills), live or mock_synthetic."),
})
}
func operationsLoopStatusOutputSchema() json.RawMessage {
return agentObjectOutputSchema([]string{"nextAction", "progressLabel", "steps", "patrolEvidenceCount", "patrolIssueEvidenceCount", "activeFindingCount", "pendingApprovalCount", "governedActionCount", "approvedDecisionCount", "rejectedDecisionCount", "verifiedOutcomeCount", "operationsLoopStarterCount", "assistantOperationsLoopStarterCount", "patrolOperationsLoopStarterCount", "patrolControlOperationsLoopStarterCount", "patrolControlCompletedOperationsLoopCount", "patrolControlResolvedOperationsLoopCount", "patrolControlValueState", "patrolAutonomyOperationsLoopStarterCount", "patrolAutonomyCompletedOperationsLoopCount", "patrolAutonomyResolvedOperationsLoopCount", "patrolAutonomyValueState", "proActivationOperationsLoopStarterCount", "proActivationCompletedOperationsLoopCount", "proActivationResolvedOperationsLoopCount", "proActivationValueProofState", "mcpOperationsLoopStarterCount", "externalAgentReady", "windowStart", "generatedAt"}, map[string]any{
"nextAction": stringEnumOption([]string{"run_patrol", "review_findings", "open_assistant", "review_approvals", "open_mcp", "complete"}, "Next recommended Patrol work action an agent or UI should take."),
@@ -892,6 +930,20 @@ var canonicalManifest = Manifest{
InputSchema: recentChangesInputSchema(),
OutputSchema: recentChangesOutputSchema(),
},
{
Name: GetMetricHistoryCapabilityName,
Title: "Get metric history",
Description: "Return how one resource's metrics moved over a window, from the history Pulse records while it monitors: 'did this disk fill gradually or suddenly?', 'was memory climbing before the restart?'. Pass the canonical resourceId from get_fleet_context, list_recent_changes or get_resource_context. Omit metric to get every metric the resource records, which also tells you their names. Points are averaged into about maxPoints buckets (window edges can add one), each with min and max. Windows longer than the install's licensed history (7 days on Community) return a license_required error.",
Category: "context",
Method: http.MethodGet,
Path: MetricHistoryCapabilityPath,
Scope: agentCapabilityScopeMonitoringRead,
ActionMode: agentCapabilityActionModeRead,
ApprovalPolicy: agentCapabilityApprovalPolicyScopeOnly,
ResponseShape: "MetricsHistory",
InputSchema: metricHistoryInputSchema(),
OutputSchema: metricHistoryOutputSchema(),
},
{
Name: OperationsLoopStatusCapabilityName,
Title: "Get Patrol work status",
@@ -1231,6 +1283,7 @@ func canonicalPulseMCPSurfaceToolNames() []string {
ListResourceCapabilitiesCapabilityName,
FleetContextCapabilityName,
ListRecentChangesCapabilityName,
GetMetricHistoryCapabilityName,
OperationsLoopStatusCapabilityName,
ListNodesCapabilityName,
AddNodeCapabilityName,
+5
View File
@@ -39,6 +39,11 @@ const (
// resource changes across the fleet: the "what changed?" companion to the
// fleet triage and per-resource context reads.
ListRecentChangesCapabilityName = "list_recent_changes"
// GetMetricHistoryCapabilityName is the manifest-owned read of one
// resource's recorded metric series: how it moved, not only where it is.
GetMetricHistoryCapabilityName = "get_metric_history"
// MetricHistoryCapabilityPath is the manifest-owned metric history route.
MetricHistoryCapabilityPath = "/api/agent/metric-history"
// FleetContextCapabilityPath is the manifest-owned fleet triage route.
FleetContextCapabilityPath = "/api/agent/fleet-context"
// RecentChangesCapabilityPath is the manifest-owned fleet change route.
+2 -1
View File
@@ -44,7 +44,8 @@ func externalAgentCapabilityActivity(capabilityName string) (string, string, boo
var activity string
switch strings.TrimSpace(capabilityName) {
case agentcapabilities.ResourceContextCapabilityName,
agentcapabilities.ListResourceCapabilitiesCapabilityName:
agentcapabilities.ListResourceCapabilitiesCapabilityName,
agentcapabilities.GetMetricHistoryCapabilityName:
activity = config.ExternalAgentActivityResourceContext
case agentcapabilities.FleetContextCapabilityName,
agentcapabilities.ListRecentChangesCapabilityName:
+1
View File
@@ -206,6 +206,7 @@ func TestAgentCapabilitiesManifest_ScopesMatchAPIAuthConstants(t *testing.T) {
agentcapabilities.ListResourceCapabilitiesCapabilityName: config.ScopeMonitoringRead,
agentcapabilities.FleetContextCapabilityName: config.ScopeMonitoringRead,
agentcapabilities.ListRecentChangesCapabilityName: config.ScopeMonitoringRead,
agentcapabilities.GetMetricHistoryCapabilityName: config.ScopeMonitoringRead,
agentcapabilities.OperationsLoopStatusCapabilityName: config.ScopeMonitoringRead,
"list_nodes": config.ScopeSettingsRead,
"add_node": config.ScopeSettingsWrite,
+98
View File
@@ -0,0 +1,98 @@
package api
import (
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
)
// handleAgentMetricHistory serves get_metric_history. Agents hold canonical
// resource ids, while the metrics store keys history by each resource's
// MetricsTarget. This resolves one into the other and hands the read to
// handleMetricsHistory, which owns tiers, fallbacks, downsampling and the
// licensed history limit, so an agent and the history charts read the same
// series under the same entitlement.
func (r *Router) handleAgentMetricHistory(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
query, problem := parseAgentMetricHistoryQuery(req.URL.Query())
if problem != "" {
http.Error(w, problem, http.StatusBadRequest)
return
}
if r.resourceHandlers == nil {
http.Error(w, "Resource inventory unavailable", http.StatusServiceUnavailable)
return
}
registry, err := r.resourceHandlers.buildRegistry(GetOrgID(req.Context()))
if err != nil || registry == nil {
http.Error(w, "Resource inventory unavailable", http.StatusServiceUnavailable)
return
}
_, canonicalID, ok := registry.GetByReference(query.resourceID)
if !ok || canonicalID == "" {
http.Error(w, "Unknown resourceId: use a canonical id from get_fleet_context", http.StatusNotFound)
return
}
// The registry derives the target from the resource's source mappings,
// as it does for the resources API; a stored resource may carry none.
metricsTarget := registry.MetricsTarget(canonicalID)
if metricsTarget == nil || metricsTarget.ResourceType == "" || metricsTarget.ResourceID == "" {
http.Error(w, "Pulse records no metric history for this resource", http.StatusNotFound)
return
}
target := url.Values{}
target.Set("resourceType", metricsTarget.ResourceType)
target.Set("resourceId", metricsTarget.ResourceID)
target.Set("range", query.timeRange)
target.Set("maxPoints", strconv.Itoa(query.maxPoints))
if query.metric != "" {
target.Set("metric", query.metric)
}
delegated := req.Clone(req.Context())
delegated.URL.RawQuery = target.Encode()
r.handleMetricsHistory(w, delegated)
}
type agentMetricHistoryQuery struct {
resourceID string
metric string
timeRange string
maxPoints int
}
// parseAgentMetricHistoryQuery reads the get_metric_history inputs. Malformed
// values are a 400 with a reason rather than a silent default, so an agent
// learns what it sent wrong.
func parseAgentMetricHistoryQuery(values url.Values) (agentMetricHistoryQuery, string) {
query := agentMetricHistoryQuery{
resourceID: strings.TrimSpace(values.Get(agentcapabilities.ResourceIDArgumentName)),
metric: strings.TrimSpace(values.Get("metric")),
timeRange: agentcapabilities.MetricHistoryDefaultRange,
maxPoints: agentcapabilities.MetricHistoryDefaultMaxPoints,
}
if query.resourceID == "" {
return query, "resourceId is required"
}
if raw := strings.ToLower(strings.TrimSpace(values.Get("range"))); raw != "" {
if !slices.Contains(agentcapabilities.MetricHistoryRanges, raw) {
return query, "range must be one of " + strings.Join(agentcapabilities.MetricHistoryRanges, ", ")
}
query.timeRange = raw
}
if raw := strings.TrimSpace(values.Get("maxPoints")); raw != "" {
maxPoints, err := strconv.Atoi(raw)
if err != nil || maxPoints < 1 {
return query, "maxPoints must be a positive integer"
}
query.maxPoints = min(maxPoints, agentcapabilities.MetricHistoryMaxPoints)
}
return query, ""
}
+125
View File
@@ -0,0 +1,125 @@
package api
import (
"encoding/json"
"math"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
// metricHistoryFixture serves one running VM through both the registry an
// agent addresses (canonical id vm:101) and the monitor the history read uses
// (metrics-store id pve1:node1:101). The VM's only history is its live point.
func metricHistoryFixture(t *testing.T, resources []unified.Resource) *Router {
t.Helper()
state := models.NewState()
state.UpdateVMsForInstance("pve1", []models.VM{{
ID: "pve1:node1:101", VMID: 101, Name: "db-01", Node: "node1", Instance: "pve1",
Status: "running", Type: "qemu", CPU: 0.42,
Memory: models.Memory{Usage: 55.0}, Disk: models.Disk{Usage: 33.0},
}})
monitor := &monitoring.Monitor{}
setUnexportedField(t, monitor, "state", state)
setUnexportedField(t, monitor, "metricsHistory", monitoring.NewMetricsHistory(10, time.Hour))
mtp := config.NewMultiTenantPersistence(t.TempDir())
if _, err := mtp.GetPersistence("default"); err != nil {
t.Fatalf("init persistence: %v", err)
}
h, _, _ := fleetFixtureHandlers(t, resources)
return &Router{monitor: monitor, licenseHandlers: NewLicenseHandlers(mtp, false), resourceHandlers: h.resources}
}
var metricHistoryVM = unified.Resource{
ID: "vm:101", Type: unified.ResourceTypeVM, Name: "db-01",
MetricsTarget: &unified.MetricsTarget{ResourceType: "vm", ResourceID: "pve1:node1:101"},
}
func getAgentMetricHistory(router *Router, values url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, agentcapabilities.MetricHistoryCapabilityPath+"?"+values.Encode(), nil)
rec := httptest.NewRecorder()
router.handleAgentMetricHistory(rec, req)
return rec
}
func TestAgentMetricHistory_ResolvesCanonicalIDToTheChartSeries(t *testing.T) {
router := metricHistoryFixture(t, []unified.Resource{metricHistoryVM})
rec := getAgentMetricHistory(router, url.Values{"resourceId": {"vm:101"}, "metric": {"cpu"}})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp metricsHistoryResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.ResourceId != "pve1:node1:101" || resp.Source != "live" || len(resp.Points) != 1 || math.Abs(resp.Points[0].Value-42.0) > 0.001 {
t.Fatalf("resp = %+v; want the VM's live cpu point from its metrics target", resp)
}
}
func TestAgentMetricHistory_PassesTheLicensedHistoryLimitThrough(t *testing.T) {
router := metricHistoryFixture(t, []unified.Resource{metricHistoryVM})
rec := getAgentMetricHistory(router, url.Values{"resourceId": {"vm:101"}, "metric": {"cpu"}, "range": {"30d"}})
if rec.Code != http.StatusPaymentRequired || !strings.Contains(rec.Body.String(), "license_required") {
t.Fatalf("30d on Community: status = %d, body=%s; want the chart read's license_required", rec.Code, rec.Body.String())
}
}
// The registry names Kubernetes targets by kind (pod, k8s-node, ...), while
// the history store keeps them under one k8s type. A pod target must reach the
// history read rather than fail as an unsupported type.
func TestAgentMetricHistory_KubernetesTargetsReachTheHistoryRead(t *testing.T) {
pod := unified.Resource{
ID: "pod:1", Type: unified.ResourceTypePod, Name: "api-0",
MetricsTarget: &unified.MetricsTarget{ResourceType: string(unified.ResourceTypePod), ResourceID: "k8s:prod:pod:default/api-0"},
}
// The fixture monitor has no persistent store and Kubernetes has no
// in-memory fallback, so the read itself answers 503; before the store
// type accepted target kinds it was a 400 for an unsupported type.
router := metricHistoryFixture(t, []unified.Resource{pod})
rec := getAgentMetricHistory(router, url.Values{"resourceId": {"pod:1"}, "metric": {"cpu"}})
if rec.Code == http.StatusBadRequest || strings.Contains(rec.Body.String(), "unsupported resourceType") {
t.Fatalf("pod target rejected before the history read: status = %d, body=%s", rec.Code, rec.Body.String())
}
}
func TestAgentMetricHistory_RejectsWhatItCannotServe(t *testing.T) {
noHistory := unified.Resource{ID: "app:1", Type: unified.ResourceTypeAppContainer, Name: "no-history"}
router := metricHistoryFixture(t, []unified.Resource{metricHistoryVM, noHistory})
for _, tc := range []struct {
values url.Values
status int
}{
{url.Values{}, http.StatusBadRequest},
{url.Values{"resourceId": {"vm:101"}, "range": {"2y"}}, http.StatusBadRequest},
{url.Values{"resourceId": {"vm:101"}, "maxPoints": {"0"}}, http.StatusBadRequest},
{url.Values{"resourceId": {"vm:101"}, "maxPoints": {"ten"}}, http.StatusBadRequest},
{url.Values{"resourceId": {"vm:999"}}, http.StatusNotFound},
{url.Values{"resourceId": {"app:1"}}, http.StatusNotFound},
} {
if rec := getAgentMetricHistory(router, tc.values); rec.Code != tc.status {
t.Errorf("%s: status = %d, body=%s; want %d", tc.values.Encode(), rec.Code, rec.Body.String(), tc.status)
}
}
}
func TestAgentMetricHistoryQuery_DefaultsAndCaps(t *testing.T) {
query, problem := parseAgentMetricHistoryQuery(url.Values{"resourceId": {" vm:101 "}})
if problem != "" || query.resourceID != "vm:101" || query.timeRange != agentcapabilities.MetricHistoryDefaultRange || query.maxPoints != agentcapabilities.MetricHistoryDefaultMaxPoints {
t.Fatalf("defaults: %+v %q", query, problem)
}
query, problem = parseAgentMetricHistoryQuery(url.Values{"resourceId": {"vm:101"}, "range": {"7D"}, "maxPoints": {"100000"}})
if problem != "" || query.timeRange != "7d" || query.maxPoints != agentcapabilities.MetricHistoryMaxPoints {
t.Fatalf("caps: %+v %q", query, problem)
}
}
+16 -9
View File
@@ -28662,20 +28662,27 @@ func TestContract_AgentRecentChangesJSONSnapshot(t *testing.T) {
}
// TestContract_PulseMCPPublishesRecentChangesAsReadOnlyTool pins that MCP
// clients see list_recent_changes as a monitoring-read GET on the manifest
// route, not only the REST surface.
// clients see the event-record reads (list_recent_changes and
// get_metric_history) as monitoring-read GETs on their manifest routes, not
// only on the REST surface.
func TestContract_PulseMCPPublishesRecentChangesAsReadOnlyTool(t *testing.T) {
manifest := agentcapabilities.CanonicalManifest()
for _, capability := range agentcapabilities.ManifestSurfaceToolCapabilities(manifest, agentcapabilities.SurfaceIDPulseMCP) {
if capability.Name != agentcapabilities.ListRecentChangesCapabilityName {
published := map[string]agentcapabilities.Capability{}
for _, capability := range agentcapabilities.ManifestSurfaceToolCapabilities(agentcapabilities.CanonicalManifest(), agentcapabilities.SurfaceIDPulseMCP) {
published[capability.Name] = capability
}
for name, path := range map[string]string{
agentcapabilities.ListRecentChangesCapabilityName: agentcapabilities.RecentChangesCapabilityPath,
agentcapabilities.GetMetricHistoryCapabilityName: agentcapabilities.MetricHistoryCapabilityPath,
} {
capability, ok := published[name]
if !ok {
t.Errorf("Pulse MCP surface does not publish %s", name)
continue
}
if capability.Method != http.MethodGet || capability.Path != agentcapabilities.RecentChangesCapabilityPath || capability.Scope != config.ScopeMonitoringRead {
t.Fatalf("list_recent_changes = %s %s (%s); want GET %s (%s)", capability.Method, capability.Path, capability.Scope, agentcapabilities.RecentChangesCapabilityPath, config.ScopeMonitoringRead)
if capability.Method != http.MethodGet || capability.Path != path || capability.Scope != config.ScopeMonitoringRead {
t.Errorf("%s = %s %s (%s); want GET %s (%s)", name, capability.Method, capability.Path, capability.Scope, path, config.ScopeMonitoringRead)
}
return
}
t.Fatalf("Pulse MCP surface does not publish %s", agentcapabilities.ListRecentChangesCapabilityName)
}
// Change events reach only subscribers in the organization whose monitor
+1
View File
@@ -302,6 +302,7 @@ var dynamicRouteAllowlist = []string{
"agentcapabilities.OperationsLoopStatusCompatibilityPath",
"agentcapabilities.PatrolControlStatusCapabilityPath",
"agentcapabilities.RecentChangesCapabilityPath",
"agentcapabilities.MetricHistoryCapabilityPath",
}
var publicRouteAllowlist = []string{
+4 -1
View File
@@ -7492,7 +7492,10 @@ func normalizeMetricsHistoryResourceType(input string) (responseType string, run
return "agent", "agent", []string{"agent", "node"}, nil
case "disk":
return "disk", "disk", []string{"disk"}, nil
case "k8s":
case "k8s", "pod", "k8s-cluster", "k8s-node", "k8s-deployment":
// Kubernetes history lives under one store type. The registry's
// MetricsTarget names the specific kind, so accept those too and let
// every caller pass a target through unchanged.
return "k8s", "k8s", []string{"k8s"}, nil
case "vm":
return "vm", "vm", []string{"vm"}, nil
@@ -77,6 +77,10 @@ func TestNormalizeMetricsHistoryResourceType_ContainerCanonicalTypes(t *testing.
wantRuntime: "app-container",
wantStore: []string{"dockerContainer", "docker"},
},
{name: "pod target", input: "pod", wantResponse: "k8s", wantRuntime: "k8s", wantStore: []string{"k8s"}},
{name: "k8s cluster target", input: "k8s-cluster", wantResponse: "k8s", wantRuntime: "k8s", wantStore: []string{"k8s"}},
{name: "k8s node target", input: "k8s-node", wantResponse: "k8s", wantRuntime: "k8s", wantStore: []string{"k8s"}},
{name: "k8s deployment target", input: "k8s-deployment", wantResponse: "k8s", wantRuntime: "k8s", wantStore: []string{"k8s"}},
}
for _, tt := range tests {
+6
View File
@@ -127,6 +127,12 @@ func (r *Router) registerMonitoringResourceRoutes(
agentcapabilities.ListRecentChangesCapabilityName,
r.agentContextHandler.HandleRecentChanges,
))))
// Agent-consumable metric history — "how did it move?" for one resource,
// served by the same history read the charts use.
r.mux.HandleFunc(agentcapabilities.MetricHistoryCapabilityPath, RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.withExternalAgentCapabilityActivity(
agentcapabilities.GetMetricHistoryCapabilityName,
r.handleAgentMetricHistory,
))))
// Content-safe Patrol control status: the shared Patrol, Assistant,
// governed action, verification, Patrol control proof, and optional MCP
// readiness projection for native UI and external agents.