Fix Patrol investigation of host filesystem alerts (#2754)

Host filesystem alerts use a disk subject that is absent from Patrol's
resource inventory, so the Investigate action rejects a valid collected
alert before inference. Resolve that exact subject through current
canonical ownership, including agents linked into guests or nodes, and
retain the original mountpoint, device, value, threshold and message in
the investigation context. Missing owners, conflicting identities and
unrelated child subjects remain rejected.

Validation:
- Full `internal/unifiedresources` and `internal/ai` package suites.
- Production `CheckHost` emission through the Patrol handler for
snapshot and unified inventory, with standalone and linked hosts.
- Repeated admission/execution resolution, active and cleared alert
evidence, identity collisions and removed owners.
- `/alerts` browser interactions at 1440 and 390 pixels: pointer and
Enter submission, accepted/busy/unresolved responses, Escape and
outside-click dismissal, and reload/re-submit. The browser forwards
actual handler requests using a synthetic estate and session. Model
diagnosis is outside this proof.
This commit is contained in:
rcourtman
2026-10-08 17:39:24 +01:00
committed by GitHub
12 changed files with 934 additions and 43 deletions
@@ -25,6 +25,27 @@ that same result. Successful reads retain their content and execution provenance
## Purpose
### Host filesystem alerts retain exact subjects in scoped Patrol
An alert-button request may name a collected child subject such as
`agent:<source-id>/disk:docker-data`. Patrol binds that reference through the
shared current-inventory resolver only when the exact collected alert identifier
and subject agree. The canonical owner becomes the collection scope. The alert
keeps its original child identity, filesystem metadata, observed value, threshold
and message in both full and budgeted model context. Related child alert evidence
is retained only for the selected current owner. Missing owners, caller-authored
child paths, names and conflicting source identities cannot broaden scope.
Linked VM, container and node owners retain their agent observations. Node/host
views of the same proven canonical owner are one identity. Automatic alert type
hints follow that owner while explicit caller type restrictions stay intact.
Admission and execution resolve independently against current collection state.
`patrol_alert_scope_test.go` covers linked owners, evidence, repeated resolution
and rejected subjects. `patrol_host_disk_scope_test.go` exercises the production
host alert evaluator and HTTP handler. The opt-in browser backend and
`scripts/check-host-disk-patrol.mjs` verify `/alerts` admission and feedback at
1440 and 390 pixels. Browser fixtures qualify admission, not model diagnosis.
### Fully cached Patrol usage — issues #2118 / #2350
Zero ordinary input tokens do not mean a run was free or skipped. Any positive
@@ -23,6 +23,21 @@ and sort the complete canonical change table while startup and ingestion wait.
## Purpose
### Collected alert subjects share current canonical ownership
`AlertResourceReferenceResolver` exposes the current-inventory identity owner
used by history correlation to runtime consumers. Supported producer child
references bind through durable source identities only. Resolution reads the
owner, canonical type and source targets under one registry read lock. It never
uses display names or retained history aliases to resurrect a removed owner.
An agent's disk, temperature, RAID and custom observations remain owned when an
operator link folds the agent into a VM, container or node. The presence of the
agent facet is required for that cross-type ownership. Patrol can therefore
select the canonical owner without interpreting alert strings independently.
`alert_scope_test.go` verifies the registry and both read adapters, rejected
references and absent current owners. Scoped Patrol tests cover linked owners
and conflicting unqualified source IDs.
### Proxmox node temperature cell follows its own open alert — issue #2068
`ProxmoxNodesTable` finds the node's own temperature alert through
+14 -5
View File
@@ -1540,6 +1540,13 @@ func buildScopeSection(scope *PatrolScope, effectiveIdentityAliases []string) st
if scope.AlertIdentifier != "" {
sb.WriteString(fmt.Sprintf("Alert Identifier: %s\n", scope.AlertIdentifier))
}
if ac := scope.AlertContext; ac != nil {
sb.WriteString(fmt.Sprintf("Collected alert subject: %q (%q)\nAlert: %s %s, value %.1f, threshold %.1f\nAlert message: %q\n",
ac.ResourceID, ac.ResourceName, ac.Level, ac.AlertType, ac.Value, ac.Threshold, ac.Message))
if ac.Mountpoint != "" || ac.Device != "" {
sb.WriteString(fmt.Sprintf("Alert filesystem: mountpoint %q, device %q.\n", ac.Mountpoint, ac.Device))
}
}
if scope.FindingID != "" {
sb.WriteString(fmt.Sprintf("Finding ID: %s\n", scope.FindingID))
}
@@ -2658,7 +2665,7 @@ func patrolActiveAlertsInScope(snap patrolRuntimeState, scopedSet map[string]boo
}
alerts := make([]models.Alert, 0, len(snap.ActiveAlerts))
for _, alert := range snap.ActiveAlerts {
if seedIsInScope(scopedSet, alert.ResourceID) {
if patrolAlertBelongsToScope(snap, alert.ResourceID, scopedSet) {
alerts = append(alerts, alert)
}
}
@@ -2671,7 +2678,7 @@ func patrolResolvedAlertsInScope(snap patrolRuntimeState, scopedSet map[string]b
}
alerts := make([]models.ResolvedAlert, 0, len(snap.RecentlyResolved))
for _, resolved := range snap.RecentlyResolved {
if seedIsInScope(scopedSet, resolved.Alert.ResourceID) {
if patrolAlertBelongsToScope(snap, resolved.Alert.ResourceID, scopedSet) {
alerts = append(alerts, resolved)
}
}
@@ -3685,16 +3692,18 @@ func (p *PatrolService) seedFindingsAndContextState(scope *PatrolScope, snap pat
sb.WriteString(fmt.Sprintf("- Guest Memory warning: %.0f%%\n", thresholds.GuestMemWarning))
sb.WriteString(fmt.Sprintf("- Guest Disk warning: %.0f%%, critical: %.0f%%\n", thresholds.GuestDiskWarn, thresholds.GuestDiskCrit))
sb.WriteString(fmt.Sprintf("- Storage warning: %.0f%%, critical: %.0f%%\n", thresholds.StorageWarning, thresholds.StorageCritical))
if scope != nil && scope.Reason == TriggerReasonAlertFired && scope.AlertContext != nil {
if scope != nil && scope.AlertContext != nil && scope.Reason != TriggerReasonAlertCleared {
ac := scope.AlertContext
level := ac.Level
if level == "" {
level = "threshold"
}
sb.WriteString(fmt.Sprintf("Note: A live %s alert just fired on the scoped resource: %s = %.1f (threshold %.1f). "+
sb.WriteString(fmt.Sprintf("Note: This check investigates the collected %s alert on %q: %s = %.1f (threshold %.1f). "+
"Investigate the root cause of THIS breach specifically: what changed, whether it is transient or sustained, the blast radius on dependent workloads, and the concrete remediation. "+
"Reporting a finding for this breach is expected. It is the reason this patrol was triggered.\n\n",
level, ac.AlertType, ac.Value, ac.Threshold))
level, ac.ResourceName, ac.AlertType, ac.Value, ac.Threshold))
} else if scope != nil && scope.AlertContext != nil && scope.Reason == TriggerReasonAlertCleared {
sb.WriteString("Note: Verify that the collected alert's breach has resolved using current evidence.\n\n")
} else {
sb.WriteString("Note: The real-time alerting system monitors these thresholds continuously. Do NOT report findings for threshold breaches. Focus on trends, capacity planning, and issues alerts cannot detect.\n\n")
}
+117
View File
@@ -0,0 +1,117 @@
package ai
import (
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
// A child reference selects its owner only through an exact collected alert.
// An arbitrary caller-authored child path cannot broaden Patrol scope.
func patrolScopeAlert(state patrolRuntimeState, scope PatrolScope) *models.Alert {
identifier := strings.TrimSpace(scope.AlertIdentifier)
if identifier == "" {
return nil
}
var found *models.Alert
if scope.Reason != TriggerReasonAlertCleared {
for i := range state.ActiveAlerts {
if state.ActiveAlerts[i].ID != identifier {
continue
}
if found != nil {
return nil
}
alert := state.ActiveAlerts[i]
found = &alert
}
if found != nil {
return found
}
}
// Recurrences reuse an alert ID. An open occurrence takes precedence;
// after clearing, use the latest collected resolution for that ID.
var latest *models.ResolvedAlert
for i := range state.RecentlyResolved {
if state.RecentlyResolved[i].Alert.ID != identifier {
continue
}
candidate := &state.RecentlyResolved[i]
if latest == nil || candidate.ResolvedTime.After(latest.ResolvedTime) {
latest = candidate
}
}
if latest == nil {
return nil
}
alert := latest.Alert
return &alert
}
func patrolAlertReferenceResolver(state patrolRuntimeState) unifiedresources.AlertResourceReferenceResolver {
if state.alertResourceResolver != nil {
return state.alertResourceResolver
}
if resolver, ok := state.readState.(unifiedresources.AlertResourceReferenceResolver); ok {
return resolver
}
if resolver, ok := state.unifiedResourceProvider.(unifiedresources.AlertResourceReferenceResolver); ok {
return resolver
}
registry := unifiedresources.NewRegistry(nil)
registry.IngestSnapshot(state.resourceSnapshot())
if state.unifiedResourceProvider != nil {
registry.IngestResources(state.unifiedResourceProvider.GetAll())
}
return registry
}
func patrolAlertOwnerIDs(resolver unifiedresources.AlertResourceReferenceResolver, binding unifiedresources.AlertResourceReference) []string {
ids := []string{binding.ResourceID}
for _, target := range binding.SourceTargets {
// Unqualified source IDs can collide across collectors. Only project
// IDs that still uniquely identify this owner into the snapshot matcher.
candidate, _ := resolver.ResolveAlertResourceReference(target.SourceID)
if candidate.ResourceID == binding.ResourceID {
ids = append(ids, target.SourceID)
}
}
return ids
}
// Keep exact child evidence when its current owner is selected. The subject's
// original identity stays on the alert; it is not an extra scoped resource.
func patrolAlertBelongsToScope(state patrolRuntimeState, resourceID string, included map[string]bool) bool {
if included[resourceID] {
return true
}
resolver := patrolAlertReferenceResolver(state)
binding, _ := resolver.ResolveAlertResourceReference(resourceID)
if binding.ResourceID == "" {
return false
}
if included[binding.ResourceID] {
return true
}
for _, target := range binding.SourceTargets {
if !included[target.SourceID] {
continue
}
candidate, _ := resolver.ResolveAlertResourceReference(target.SourceID)
if candidate.ResourceID == binding.ResourceID {
return true
}
}
return false
}
func patrolAlertContext(alert *models.Alert) *PatrolAlertContext {
if alert == nil {
return nil
}
metadataString := func(key string) string { value, _ := alert.Metadata[key].(string); return value }
return &PatrolAlertContext{AlertType: alert.Type, Level: alert.Level, Value: alert.Value, Threshold: alert.Threshold,
Message: alert.Message, ResourceID: alert.ResourceID, ResourceName: alert.ResourceName,
Mountpoint: metadataString("mountpoint"), Device: metadataString("device")}
}
+169
View File
@@ -0,0 +1,169 @@
package ai
import (
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
// Control: selecting a known owner must preserve the exact triggering child
// alert. This exposes why a string-prefix rewrite alone is not a complete fix.
func TestSupportHostDiskOwnerScopeRetainsTrigger(t *testing.T) {
host := models.Host{ID: "repro-host-source-01", Hostname: "docker-host-ext-01", MachineID: "repro-machine-01", Disks: []models.Disk{{Mountpoint: "/docker-data", Usage: 85}}}
alert := models.Alert{ID: "repro-disk-alert", ResourceID: "agent:repro-host-source-01/disk:docker-data", Type: "disk", Metadata: map[string]interface{}{"hostId": host.ID, "mountpoint": "/docker-data"}}
snapshot := models.StateSnapshot{Hosts: []models.Host{host}, ActiveAlerts: []models.Alert{alert}}
registry := unifiedresources.NewRegistry(nil)
registry.IngestSnapshot(snapshot)
for _, unified := range []bool{false, true} {
name := "snapshot"
state := newPatrolRuntimeState(snapshot)
parentID := host.ID
if unified {
name = "unified"
state = newPatrolRuntimeStateWithProviders(snapshot, registry, unifiedresources.NewUnifiedAIAdapter(registry))
_, id, found := registry.GetByReference(host.ID)
if !found {
t.Fatal("canonical owner missing")
}
parentID = id
}
t.Run(name, func(t *testing.T) {
resolved, resolution := resolvePatrolScopeState(state, PatrolScope{ResourceIDs: []string{parentID}, AlertIdentifier: alert.ID})
if len(resolution.UnmatchedResourceIDs) != 0 || len(resolution.EffectiveResourceIDs) == 0 {
t.Fatalf("owner control did not resolve: %+v", resolution)
}
filtered := filterPatrolStateByScopeState(state, resolved)
t.Logf("resolved=%+v effective=%v hosts=%d active_alerts=%d", resolved, resolution.EffectiveResourceIDs, len(filtered.Hosts), len(filtered.ActiveAlerts))
if len(filtered.ActiveAlerts) != 1 || filtered.ActiveAlerts[0].ID != alert.ID {
t.Fatalf("owner scope discarded the exact disk alert despite AlertIdentifier=%q", alert.ID)
}
})
}
}
func TestPatrolDiskAlertRecurrenceUsesCurrentEvidence(t *testing.T) {
active := models.Alert{ID: "disk-alert", ResourceID: "agent:source-host/disk:data", Type: "disk", Value: 85, Threshold: 85}
old := active
old.Value = 95
latest := active
latest.Value = 75
state := newPatrolRuntimeState(models.StateSnapshot{Hosts: []models.Host{{ID: "source-host", MachineID: "machine"}}, ActiveAlerts: []models.Alert{active}, RecentlyResolved: []models.ResolvedAlert{{Alert: old, ResolvedTime: time.Unix(1, 0)}, {Alert: latest, ResolvedTime: time.Unix(2, 0)}}})
for _, tc := range []struct {
reason TriggerReason
value float64
}{{TriggerReasonManual, 85}, {TriggerReasonAlertCleared, 75}} {
resolved, resolution := resolvePatrolScopeState(state, PatrolScope{ResourceIDs: []string{active.ResourceID}, AlertIdentifier: active.ID, Reason: tc.reason})
if len(resolution.EffectiveResourceIDs) == 0 || resolved.AlertContext == nil || resolved.AlertContext.Value != tc.value {
t.Fatalf("wrong occurrence evidence for %s: %+v, %+v", tc.reason, resolution, resolved.AlertContext)
}
}
}
func TestPatrolDiskAlertOwnerAndEvidence(t *testing.T) {
for _, ownerType := range []string{"agent", "vm", "system-container", "node"} {
t.Run(ownerType, func(t *testing.T) {
host := models.Host{ID: "disk-host-source", Hostname: "disk-host", MachineID: "disk-machine"}
alert := models.Alert{ID: "disk-alert", ResourceID: "agent:disk-host-source/disk:docker-data", ResourceName: "disk-host (/docker-data)", Type: "disk", Level: "warning", Value: 85, Threshold: 85, Message: "Disk usage reached 85%", Metadata: map[string]interface{}{"mountpoint": "/docker-data", "device": "/dev/sdb1"}}
unrelated := models.Alert{ID: "other-alert", ResourceID: "agent:other-host/disk:docker-data", Type: "disk"}
snapshot := models.StateSnapshot{Hosts: []models.Host{host, {ID: "other-host", MachineID: "other-machine"}}, ActiveAlerts: []models.Alert{alert, unrelated}, RecentlyResolved: []models.ResolvedAlert{{Alert: alert}, {Alert: unrelated}}}
// Keep this occurrence distinct; ID reuse is covered separately above.
snapshot.RecentlyResolved[0].ID = "resolved-disk-alert"
ownerSourceID := host.ID
switch ownerType {
case "vm":
ownerSourceID = "vm-owner"
snapshot.VMs = []models.VM{{ID: ownerSourceID, VMID: 101, Instance: "pve", Node: "node-owner", Name: "linked-vm"}}
case "system-container":
ownerSourceID = "ct-owner"
snapshot.Containers = []models.Container{{ID: ownerSourceID, VMID: 102, Instance: "pve", Node: "node-owner", Name: "linked-container"}}
case "node":
ownerSourceID = "node-owner"
snapshot.Nodes = []models.Node{{ID: ownerSourceID, Name: "linked-node", Instance: "pve"}}
}
store := unifiedresources.NewMemoryStore()
registry := unifiedresources.NewRegistry(store)
registry.IngestSnapshot(snapshot)
_, hostID, hostFound := registry.GetByReference(host.ID)
_, ownerID, ownerFound := registry.GetByReference(ownerSourceID)
if !hostFound || !ownerFound {
t.Fatal("fixture owner missing")
}
if ownerType != "agent" {
if err := store.AddLink(unifiedresources.ResourceLink{ResourceA: hostID, ResourceB: ownerID, PrimaryID: ownerID}); err != nil {
t.Fatal(err)
}
registry = unifiedresources.NewRegistry(store)
registry.IngestSnapshot(snapshot)
}
state := newPatrolRuntimeStateWithProviders(snapshot, registry, unifiedresources.NewUnifiedAIAdapter(registry))
for _, scope := range []PatrolScope{
{ResourceIDs: []string{alert.ResourceID}, AlertIdentifier: alert.ID, Reason: TriggerReasonManual},
AlertTriggeredPatrolScope(alert.ID, alert.ResourceID, "agent", "disk"),
AlertClearedPatrolScope("resolved-disk-alert", alert.ResourceID, "agent"),
} {
resolved, resolution := resolvePatrolScopeState(state, scope)
if len(resolution.UnmatchedResourceIDs)+len(resolution.AmbiguousResourceIDs) != 0 || len(resolution.EffectiveResourceIDs) == 0 {
t.Fatalf("%s admission failed: %+v", scope.Reason, resolution)
}
// Admission and execution each resolve against current inventory.
resolved, resolution = resolvePatrolScopeState(state, resolved)
if len(resolution.UnmatchedResourceIDs)+len(resolution.AmbiguousResourceIDs) != 0 || len(resolution.EffectiveResourceIDs) == 0 {
t.Fatalf("%s execution failed: %+v", scope.Reason, resolution)
}
filtered := filterPatrolStateByScopeState(state, resolved)
if len(filtered.ActiveAlerts) != 1 || filtered.ActiveAlerts[0].ResourceID != alert.ResourceID || len(filtered.RecentlyResolved) != 1 {
t.Fatalf("lost subject or leaked neighbor evidence: %+v", filtered.ActiveAlerts)
}
if resource := filtered.unifiedResourceProvider.GetAll(); len(resource) != 1 || resource[0].ID != ownerID {
t.Fatalf("scope expanded beyond canonical owner: %+v", resource)
}
if resolved.AlertContext == nil || resolved.AlertContext.ResourceID != alert.ResourceID || resolved.AlertContext.Value != 85 || resolved.AlertContext.Threshold != 85 {
t.Fatalf("lost trusted alert evidence: %+v", resolved.AlertContext)
}
prompt := buildScopeSection(&resolved, resolution.EffectiveResourceIDs)
for _, evidence := range []string{alert.ResourceID, "/docker-data", "/dev/sdb1", "value 85.0, threshold 85.0", alert.Message} {
if !strings.Contains(prompt, evidence) {
t.Fatalf("missing %q in prompt: %s", evidence, prompt)
}
}
}
})
}
}
func TestPatrolDiskAlertRejectsUntrustedSubjects(t *testing.T) {
for _, scenario := range []string{"missing-alert", "wrong-alert", "duplicate-alert", "wrong-subject", "removed-owner", "display-name-owner", "malformed-child", "source-collision"} {
t.Run(scenario, func(t *testing.T) {
alert := models.Alert{ID: "disk-alert", ResourceID: "agent:disk-host-source/disk:docker-data", Type: "disk"}
snapshot := models.StateSnapshot{Hosts: []models.Host{{ID: "disk-host-source", Hostname: "disk-host", MachineID: "machine"}}, ActiveAlerts: []models.Alert{alert}}
scope := PatrolScope{ResourceIDs: []string{alert.ResourceID}, AlertIdentifier: alert.ID, AlertContext: &PatrolAlertContext{Message: "caller-authored"}}
switch scenario {
case "missing-alert":
snapshot.ActiveAlerts = nil
case "wrong-alert":
scope.AlertIdentifier = "different-alert"
case "duplicate-alert":
snapshot.ActiveAlerts = append(snapshot.ActiveAlerts, alert)
case "wrong-subject":
scope.ResourceIDs[0] = "agent:disk-host-source/disk:other"
case "removed-owner":
snapshot.Hosts = nil
case "display-name-owner":
snapshot.ActiveAlerts[0].ResourceID = "agent:disk-host/disk:docker-data"
scope.ResourceIDs[0] = snapshot.ActiveAlerts[0].ResourceID
case "malformed-child":
snapshot.ActiveAlerts[0].ResourceID += "/nested"
scope.ResourceIDs[0] = snapshot.ActiveAlerts[0].ResourceID
case "source-collision":
snapshot.Nodes = []models.Node{{ID: "disk-host-source", Name: "unrelated-node", Instance: "pve"}}
}
resolved, resolution := resolvePatrolScopeState(newPatrolRuntimeState(snapshot), scope)
if len(resolution.UnmatchedResourceIDs)+len(resolution.AmbiguousResourceIDs) != 1 || len(resolution.EffectiveResourceIDs) != 0 || resolved.AlertContext != nil {
t.Fatalf("unsafe scope accepted: %+v, context=%+v", resolution, resolved.AlertContext)
}
})
}
}
+12 -6
View File
@@ -1857,20 +1857,20 @@ func patrolKubernetesScopeName(k models.KubernetesCluster) string {
return clusterName
}
func collectPatrolScopedActiveAlerts(alerts []models.Alert, includedResourceIDs map[string]bool) []models.Alert {
func collectPatrolScopedActiveAlerts(state patrolRuntimeState, alerts []models.Alert, includedResourceIDs map[string]bool) []models.Alert {
filtered := make([]models.Alert, 0, len(alerts))
for _, alert := range alerts {
if includedResourceIDs[alert.ResourceID] {
if patrolAlertBelongsToScope(state, alert.ResourceID, includedResourceIDs) {
filtered = append(filtered, alert)
}
}
return filtered
}
func collectPatrolScopedResolvedAlerts(alerts []models.ResolvedAlert, includedResourceIDs map[string]bool) []models.ResolvedAlert {
func collectPatrolScopedResolvedAlerts(state patrolRuntimeState, alerts []models.ResolvedAlert, includedResourceIDs map[string]bool) []models.ResolvedAlert {
filtered := make([]models.ResolvedAlert, 0, len(alerts))
for _, resolved := range alerts {
if includedResourceIDs[resolved.ResourceID] {
if patrolAlertBelongsToScope(state, resolved.ResourceID, includedResourceIDs) {
filtered = append(filtered, resolved)
}
}
@@ -1930,10 +1930,10 @@ func collectPatrolScopedPBSBackups(backups []models.PBSBackup, includedGuestVMID
func copyScopedPatrolMetadata(dst *patrolRuntimeState, snap patrolRuntimeState, includedResourceIDs map[string]bool, includedGuestVMIDs map[int]bool) {
if len(snap.ActiveAlerts) > 0 {
dst.ActiveAlerts = collectPatrolScopedActiveAlerts(snap.ActiveAlerts, includedResourceIDs)
dst.ActiveAlerts = collectPatrolScopedActiveAlerts(snap, snap.ActiveAlerts, includedResourceIDs)
}
if len(snap.RecentlyResolved) > 0 {
dst.RecentlyResolved = collectPatrolScopedResolvedAlerts(snap.RecentlyResolved, includedResourceIDs)
dst.RecentlyResolved = collectPatrolScopedResolvedAlerts(snap, snap.RecentlyResolved, includedResourceIDs)
}
if len(snap.ConnectionHealth) > 0 {
dst.ConnectionHealth = collectPatrolScopedConnectionHealth(snap.ConnectionHealth, includedResourceIDs)
@@ -1949,6 +1949,9 @@ func copyScopedPatrolMetadata(dst *patrolRuntimeState, snap patrolRuntimeState,
}
func filterPatrolStateByScopeState(snap patrolRuntimeState, scope PatrolScope) patrolRuntimeState {
if len(snap.ActiveAlerts)+len(snap.RecentlyResolved) > 0 {
snap.alertResourceResolver = patrolAlertReferenceResolver(snap)
}
matcher := newPatrolScopeMatcher(scope)
filterState := newPatrolScopedFilterState(snap)
@@ -2006,6 +2009,9 @@ func filterPatrolStateByScopeState(snap patrolRuntimeState, scope PatrolScope) p
// context.
filteredUnified, unifiedIDs := collectPatrolScopedUnifiedResources(snap.unifiedResourceProvider, matcher)
filterState.includeResourceID(unifiedIDs...)
if len(filteredUnified) > 0 {
filterState.filtered.alertResourceResolver = patrolAlertReferenceResolver(snap)
}
copyScopedPatrolMetadata(&filterState.filtered, snap, filterState.includedResourceIDs, filterState.includedGuestVMIDs)
+106 -9
View File
@@ -77,6 +77,7 @@ type patrolRuntimeResourceRecord struct {
// It intentionally includes only the fields patrol currently consumes, so the
// subsystem can stop mirroring the full global StateSnapshot shape.
type patrolRuntimeState struct {
alertResourceResolver unifiedresources.AlertResourceReferenceResolver
readState unifiedresources.ReadState
unifiedResourceProvider UnifiedResourceProvider
Nodes []models.Node
@@ -170,7 +171,39 @@ func (s patrolRuntimeState) withDerivedProvidersAndResources(resources []unified
registry := unifiedresources.NewRegistry(nil)
registry.IngestSnapshot(s.resourceSnapshot())
if len(resources) > 0 {
registry.IngestResources(resources)
// Already-unified rows own their source observations. Re-ingesting a
// linked agent snapshot without its operator link would invent a second
// host beside the canonical guest/node. Keep only supplemental snapshot
// rows whose source observations are not already fully represented.
resolver := patrolAlertReferenceResolver(s)
type sourceKey struct {
source unifiedresources.DataSource
id string
}
covered := make(map[sourceKey]bool)
for _, resource := range resources {
binding, _ := resolver.ResolveAlertResourceReference(resource.ID)
for _, target := range binding.SourceTargets {
covered[sourceKey{target.Source, target.SourceID}] = true
}
}
retained := make([]unifiedresources.Resource, 0)
bindings := registry.AlertResourceReferences()
for _, resource := range registry.List() {
targets := bindings[resource.ID].SourceTargets
fullyCovered := len(targets) > 0
for _, target := range targets {
if !covered[sourceKey{target.Source, target.SourceID}] {
fullyCovered = false
break
}
}
if !fullyCovered {
retained = append(retained, resource)
}
}
registry = unifiedresources.NewRegistry(nil)
registry.IngestResources(append(retained, resources...))
}
s.readState = registry
s.unifiedResourceProvider = unifiedresources.NewUnifiedAIAdapter(registry)
@@ -540,10 +573,23 @@ func canonicalPatrolRuntimeResourceType(kind patrolRuntimeResourceKind) string {
// unique known aliases onto the complete identity set for the matching Patrol
// runtime record. It never fuzzy-matches and refuses ambiguous aliases.
func resolvePatrolScopeState(state patrolRuntimeState, scope PatrolScope) (PatrolScope, PatrolScopeResolution) {
if scope.AlertIdentifier != "" {
scope.AlertContext = nil
state.alertResourceResolver = patrolAlertReferenceResolver(state)
}
// Recompute private type evidence from the current runtime snapshot on every
// resolution pass; callers may synchronously pre-resolve and the accepted run
// resolves again to close collection races.
scope.resolvedResourceTypes = nil
alert := patrolScopeAlert(state, scope)
var alertOwner unifiedresources.AlertResourceReference
var alertOwnerIDs []string
if alert != nil {
alertOwner, _ = patrolAlertReferenceResolver(state).ResolveAlertResourceReference(alert.ResourceID)
if alertOwner.ResourceID != "" {
alertOwnerIDs = patrolAlertOwnerIDs(patrolAlertReferenceResolver(state), alertOwner)
}
}
resolution := PatrolScopeResolution{}
for _, requested := range scope.ResourceIDs {
if requested = strings.TrimSpace(requested); requested != "" {
@@ -628,21 +674,72 @@ func resolvePatrolScopeState(state patrolRuntimeState, scope PatrolScope) (Patro
}
}
}
switch len(matches) {
case 0:
resolution.UnmatchedResourceIDs = append(resolution.UnmatchedResourceIDs, requested)
case 1:
for _, id := range matches[0].ids {
addExpanded(id)
addResolved(id)
if len(matches) == 0 && alert != nil && alertOwner.ResourceID != "" {
requestedAlertIdentity := requested == alert.ResourceID
for _, ownerID := range alertOwnerIDs {
requestedAlertIdentity = requestedAlertIdentity || requested == ownerID
}
if requestedAlertIdentity {
for _, ownerID := range alertOwnerIDs {
for _, record := range records {
if record.idTokens[canonicalPatrolScopeToken(ownerID)] {
matches = append(matches, record)
}
}
if len(matches) > 0 {
break
}
}
}
}
// A linked node may expose both node and host views of one canonical
// resource. Only collected-alert ownership can prove those facets are
// the same identity; unrelated ID/alias collisions remain ambiguous.
sameAlertOwner := alertOwner.ResourceID != "" && len(matches) > 0
if sameAlertOwner {
for _, record := range matches {
owner, _ := patrolAlertReferenceResolver(state).ResolveAlertResourceReference(record.ids[0])
if owner.ResourceID != alertOwner.ResourceID {
sameAlertOwner = false
break
}
}
}
switch {
case len(matches) == 0:
resolution.UnmatchedResourceIDs = append(resolution.UnmatchedResourceIDs, requested)
case len(matches) == 1 || sameAlertOwner:
for _, record := range matches {
for _, id := range record.ids {
addExpanded(id)
addResolved(id)
}
addResolvedType(record.resourceType)
}
if sameAlertOwner {
for _, id := range alertOwnerIDs {
addExpanded(id)
addResolved(id)
}
}
addResolvedType(matches[0].resourceType)
default:
resolution.AmbiguousResourceIDs = append(resolution.AmbiguousResourceIDs, requested)
}
}
scope.ResourceIDs = expanded
scope.resolvedIdentityOnly = len(expanded) > 0
if alert != nil {
included := make(map[string]bool, len(expanded))
for _, id := range expanded {
included[id] = true
}
if patrolAlertBelongsToScope(state, alert.ResourceID, included) {
scope.AlertContext = patrolAlertContext(alert)
if scope.alertResourceTypeHint {
scope.ResourceTypes = append([]string(nil), scope.resolvedResourceTypes...)
}
}
}
if len(expanded) > 0 {
filtered := filterPatrolStateByScopeState(state, scope)
resolution.EffectiveResourceIDs = patrolRuntimeSortedResourceIDs(filtered)
+28 -19
View File
@@ -108,6 +108,9 @@ type PatrolScope struct {
// scopes. It is used only to reduce the provider tool manifest; it never
// broadens collection scope or mutation authority.
resolvedResourceTypes []string
// Alert constructors receive the producer's type hint. Current canonical
// ownership can replace that hint when an agent is linked into a guest/node.
alertResourceTypeHint bool
}
// PatrolScopeResolution records how caller-supplied identities map onto the
@@ -123,11 +126,15 @@ type PatrolScopeResolution struct {
// PatrolAlertContext describes the alert that triggered a scoped patrol, so the
// patrol prompt can focus the investigation on the specific breach.
type PatrolAlertContext struct {
AlertType string // cpu, memory, disk, etc.
Level string // warning | critical
Value float64 // observed metric value at fire time
Threshold float64 // threshold that was crossed
Message string // human-readable alert message
ResourceID string
ResourceName string
Mountpoint string
Device string
AlertType string // cpu, memory, disk, etc.
Level string // warning | critical
Value float64 // observed metric value at fire time
Threshold float64 // threshold that was crossed
Message string // human-readable alert message
}
// PatrolDepth controls how thorough a patrol run should be
@@ -693,26 +700,28 @@ func patrolScopeResourceTypes(resourceType string) []string {
// AlertTriggeredPatrolScope creates a patrol scope for an alert that fired
func AlertTriggeredPatrolScope(alertIdentifier, resourceID, resourceType, alertType string) PatrolScope {
return PatrolScope{
ResourceIDs: []string{resourceID},
ResourceTypes: patrolScopeResourceTypes(resourceType),
Depth: PatrolDepthQuick,
Reason: TriggerReasonAlertFired,
Context: "Alert: " + alertType,
Priority: triggerPriorityAlertFired,
AlertIdentifier: alertIdentifier,
ResourceIDs: []string{resourceID},
ResourceTypes: patrolScopeResourceTypes(resourceType),
Depth: PatrolDepthQuick,
Reason: TriggerReasonAlertFired,
Context: "Alert: " + alertType,
Priority: triggerPriorityAlertFired,
AlertIdentifier: alertIdentifier,
alertResourceTypeHint: true,
}
}
// AlertClearedPatrolScope creates a patrol scope for an alert that cleared
func AlertClearedPatrolScope(alertIdentifier, resourceID, resourceType string) PatrolScope {
return PatrolScope{
ResourceIDs: []string{resourceID},
ResourceTypes: patrolScopeResourceTypes(resourceType),
Depth: PatrolDepthQuick,
Reason: TriggerReasonAlertCleared,
Context: "Verify resolution",
Priority: triggerPriorityAlertCleared,
AlertIdentifier: alertIdentifier,
ResourceIDs: []string{resourceID},
ResourceTypes: patrolScopeResourceTypes(resourceType),
Depth: PatrolDepthQuick,
Reason: TriggerReasonAlertCleared,
Context: "Verify resolution",
Priority: triggerPriorityAlertCleared,
AlertIdentifier: alertIdentifier,
alertResourceTypeHint: true,
}
}
+197
View File
@@ -0,0 +1,197 @@
package api
import (
"bytes"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
// Reproduce the alert button's exact request using a disk alert emitted by
// CheckHost. Keep Patrol busy so successful identity admission cannot call a
// model or start a background run. A valid identity must reach the busy guard.
func TestSupportHostDiskInvestigationContract(t *testing.T) {
for _, linked := range []bool{false, true} {
for _, unified := range []bool{false, true} {
name := "snapshot"
if unified {
name = "unified"
}
if linked {
name += "-linked-vm"
} else {
name += "-standalone"
}
t.Run(name, func(t *testing.T) {
host := models.Host{
ID: "repro-host-source-01", Hostname: "docker-host-ext-01",
DisplayName: "docker-host-ext-01", Status: "online",
MachineID: "repro-machine-01", LastSeen: time.Now(),
Disks: []models.Disk{{Mountpoint: "/docker-data", Device: "/dev/sdb1", Usage: 85, Total: 1000, Used: 850, Free: 150}},
}
snapshot := models.StateSnapshot{Hosts: []models.Host{host, {
ID: "unrelated-host", Hostname: "unrelated", MachineID: "unrelated-machine", Status: "online",
}}}
if linked {
host.LinkedVMID = "vm-101"
snapshot.Hosts[0] = host
snapshot.VMs = []models.VM{{ID: "vm-101", Name: host.Hostname, VMID: 101, Node: "pve-1", Instance: "pve", Status: "running"}}
}
manager := alerts.NewManagerWithDataDir(t.TempDir(), alerts.WithoutPersistedAlertRestore())
t.Cleanup(manager.Stop)
// The production evaluator is unchanged; only temporal delay and
// unrelated default policies are disabled for this immediate proof.
setUnexportedField(t, manager, "config", alerts.AlertConfig{
Enabled: true, ActivationState: alerts.ActivationActive,
AgentDefaults: alerts.ThresholdConfig{Disk: &alerts.HysteresisThreshold{Trigger: 85, Clear: 80}},
Overrides: map[string]alerts.ThresholdConfig{}, TimeThresholds: map[string]int{},
})
manager.CheckHost(host)
active := manager.GetActiveAlerts()
if len(active) != 1 {
t.Fatalf("disk fixture did not produce exactly one alert: %+v", active)
}
alert := active[0]
if alert.ResourceID != "agent:repro-host-source-01/disk:docker-data" || alert.Type != "disk" {
t.Fatalf("unexpected emitted disk identity: %+v", alert)
}
t.Logf("emitted resource_id=%q resource_name=%q hostId=%v mountpoint=%v", alert.ResourceID, alert.ResourceName, alert.Metadata["hostId"], alert.Metadata["mountpoint"])
snapshot.ActiveAlerts = []models.Alert{hostDiskPatrolStateAlert(alert)}
handler, patrol, _, _ := setupAIHandlerWithPatrol(t)
seedReadyAnthropicPatrolRuntime(t, handler)
handler.defaultAIService.SetStateProvider(&scopedPatrolStateProvider{state: snapshot})
parentID := host.ID
if unified {
store := unifiedresources.NewMemoryStore()
registry := unifiedresources.NewRegistry(store)
registry.IngestSnapshot(snapshot)
if linked {
_, hostCanonicalID, hostFound := registry.GetByReference(host.ID)
_, vmCanonicalID, vmFound := registry.GetByReference("vm-101")
if !hostFound || !vmFound {
t.Fatal("manual-link fixture is missing a source")
}
if err := store.AddLink(unifiedresources.ResourceLink{ResourceA: hostCanonicalID, ResourceB: vmCanonicalID, PrimaryID: vmCanonicalID}); err != nil {
t.Fatal(err)
}
registry = unifiedresources.NewRegistry(store)
registry.IngestSnapshot(snapshot)
_, foldedOwner, found := registry.GetByReference(host.ID)
if !found || foldedOwner != vmCanonicalID {
t.Fatalf("fixture host did not merge into VM: %q want %q", foldedOwner, vmCanonicalID)
}
t.Logf("manual-link host=%q folded into VM=%q", hostCanonicalID, foldedOwner)
}
handler.defaultAIService.SetReadState(registry)
patrol.SetUnifiedResourceProvider(unifiedresources.NewUnifiedAIAdapter(registry))
resource, canonicalID, ok := registry.GetByReference(host.ID)
if !ok {
t.Fatalf("host source identity missing from canonical registry")
}
parentID = canonicalID
t.Logf("canonical owner id=%q type=%q aliases=%+v sources=%+v", canonicalID, resource.Type, resource.Canonical, registry.SourceTargets(canonicalID))
if _, _, ok := registry.GetByReference(alert.ResourceID); ok {
t.Log("disk alert unexpectedly has a canonical registry resource")
}
}
_, parentResolution := patrol.ResolvePatrolScope(ai.PatrolScope{ResourceIDs: []string{parentID}})
if len(parentResolution.UnmatchedResourceIDs) != 0 || len(parentResolution.AmbiguousResourceIDs) != 0 || len(parentResolution.EffectiveResourceIDs) == 0 {
t.Fatalf("parent control did not resolve: %+v", parentResolution)
}
t.Logf("parent control resolution=%+v", parentResolution)
_, diskResolution := patrol.ResolvePatrolScope(ai.PatrolScope{ResourceIDs: []string{alert.ResourceID}, AlertIdentifier: alert.ID})
t.Logf("disk resolution=%+v", diskResolution)
setUnexportedField(t, patrol, "runInProgress", true)
setUnexportedField(t, patrol, "currentRunID", "repro-busy-sentinel")
request := func(resourceID string) *httptest.ResponseRecorder {
body, err := json.Marshal(map[string]any{"resource_ids": []string{resourceID}, "alert_identifier": alert.ID, "alert_type": alert.Type})
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
handler.HandleForcePatrol(rec, newLoopbackRequest(http.MethodPost, "/api/ai/patrol/run", bytes.NewReader(body)))
return rec
}
control := request(parentID)
if control.Code != http.StatusConflict || !strings.Contains(control.Body.String(), "patrol_already_running") {
t.Fatalf("known parent did not reach downstream busy guard: %d %s", control.Code, control.Body.String())
}
rec := request(alert.ResourceID)
t.Logf("disk HTTP status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "patrol_already_running") {
t.Fatalf("disk alert must resolve before busy admission, got %d %s", rec.Code, rec.Body.String())
}
})
}
}
}
// Opt-in loopback backend for scripts/check-host-disk-patrol.mjs. The browser
// exercises the real admission handler against a production-emitted alert.
// Inference is absent, so acceptance is not a claim of completed diagnosis.
func TestPatrolHostDiskBrowserBackend(t *testing.T) {
address := os.Getenv("PULSE_HOST_DISK_BROWSER_ADDRESS")
if address == "" {
t.Skip("opt-in browser admission fixture")
}
hostIP, _, err := net.SplitHostPort(address)
if err != nil || hostIP != "127.0.0.1" {
t.Fatal("browser fixture requires a loopback address")
}
host := models.Host{ID: "repro-host-source-01", Hostname: "docker-host-ext-01", MachineID: "repro-machine-01", Status: "online", LastSeen: time.Now(), Disks: []models.Disk{{Mountpoint: "/docker-data", Device: "/dev/sdb1", Usage: 85, Total: 1000, Used: 850, Free: 150}}}
manager := alerts.NewManagerWithDataDir(t.TempDir(), alerts.WithoutPersistedAlertRestore())
t.Cleanup(manager.Stop)
setUnexportedField(t, manager, "config", alerts.AlertConfig{Enabled: true, ActivationState: alerts.ActivationActive, AgentDefaults: alerts.ThresholdConfig{Disk: &alerts.HysteresisThreshold{Trigger: 85, Clear: 80}}, Overrides: map[string]alerts.ThresholdConfig{}, TimeThresholds: map[string]int{}})
manager.CheckHost(host)
active := manager.GetActiveAlerts()
if len(active) != 1 {
t.Fatalf("fixture alert missing: %+v", active)
}
stop := make(chan struct{}, 1)
mux := http.NewServeMux()
mux.HandleFunc("/proof/alert", func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(active[0]) })
mux.HandleFunc("/proof/stop", func(w http.ResponseWriter, r *http.Request) { stop <- struct{}{} })
mux.HandleFunc("/api/ai/patrol/run", func(w http.ResponseWriter, r *http.Request) {
handler, patrol, _, _ := setupAIHandlerWithPatrol(t)
seedReadyAnthropicPatrolRuntime(t, handler)
handler.defaultAIService.SetChatService(nil)
snapshot := models.StateSnapshot{Hosts: []models.Host{host}, ActiveAlerts: []models.Alert{hostDiskPatrolStateAlert(active[0])}}
if r.URL.Query().Get("case") == "unresolved" {
snapshot.Hosts = nil
}
handler.defaultAIService.SetStateProvider(&scopedPatrolStateProvider{state: snapshot})
if r.URL.Query().Get("case") == "busy" {
setUnexportedField(t, patrol, "runInProgress", true)
setUnexportedField(t, patrol, "currentRunID", "browser-busy")
}
handler.HandleForcePatrol(w, r)
})
listener, err := net.Listen("tcp", address)
if err != nil {
t.Fatal(err)
}
server := httptest.NewUnstartedServer(mux)
server.Listener = listener
server.Start()
t.Cleanup(server.Close)
t.Logf("browser admission fixture ready at %s", server.URL)
select {
case <-stop:
case <-time.After(4 * time.Minute):
t.Fatal("browser fixture was not stopped")
}
}
func hostDiskPatrolStateAlert(alert alerts.Alert) models.Alert {
return models.Alert{ID: alert.ID, Type: alert.Type, Level: string(alert.Level), ResourceID: alert.ResourceID, ResourceName: alert.ResourceName, Message: alert.Message, Value: alert.Value, Threshold: alert.Threshold, StartTime: alert.StartTime, Metadata: alert.Metadata}
}
+85 -4
View File
@@ -10,6 +10,76 @@ import (
"time"
)
// AlertResourceReference binds a collected alert subject to current canonical
// inventory. Source targets are durable identities, never display names.
type AlertResourceReference struct {
ResourceID string
ResourceType ResourceType
SourceTargets []SourceTarget
}
type AlertResourceReferenceResolver interface {
ResolveAlertResourceReference(string) (AlertResourceReference, bool)
}
// AlertResourceReferences captures current source ownership in one generation.
// Scoped projections use this batch view instead of scanning every source
// mapping once per resource when rebuilding a read-only registry.
func (rr *ResourceRegistry) AlertResourceReferences() map[string]AlertResourceReference {
if rr == nil {
return nil
}
rr.mu.RLock()
defer rr.mu.RUnlock()
targets := rr.cachedSourceTargets
if rr.viewsDirty || targets == nil {
targets = rr.buildSourceTargetsIndexLocked()
}
bindings := make(map[string]AlertResourceReference, len(rr.resources))
for id, resource := range rr.resources {
bindings[id] = AlertResourceReference{ResourceID: id, ResourceType: resource.Type, SourceTargets: append([]SourceTarget(nil), targets[id]...)}
}
return bindings
}
// ResolveAlertResourceReference shares current-inventory identity with history
// correlation. It never consults retained history bindings or matches names.
// claimed is true for an inventory conflict as well as a successful binding;
// conflicts have an empty ResourceID and must not fall back to another source.
func (rr *ResourceRegistry) ResolveAlertResourceReference(ref string) (AlertResourceReference, bool) {
if rr == nil {
return AlertResourceReference{}, false
}
rr.mu.RLock()
defer rr.mu.RUnlock()
id, claimed := rr.resolveHistoryReferenceLocked(ref)
if id == "" {
return AlertResourceReference{}, claimed
}
resource := rr.resources[id]
if resource == nil {
return AlertResourceReference{}, true
}
var targets []SourceTarget
if !rr.viewsDirty && rr.cachedSourceTargets != nil {
targets = append([]SourceTarget(nil), rr.cachedSourceTargets[id]...)
} else {
targets = rr.collectSourceTargetsLocked(id, resource.Type)
}
return AlertResourceReference{ResourceID: id, ResourceType: resource.Type, SourceTargets: targets}, true
}
func (a *UnifiedAIAdapter) ResolveAlertResourceReference(ref string) (AlertResourceReference, bool) {
if a == nil || a.registry == nil {
return AlertResourceReference{}, false
}
return a.registry.ResolveAlertResourceReference(ref)
}
func (a *MonitorAdapter) ResolveAlertResourceReference(ref string) (AlertResourceReference, bool) {
return a.currentRegistry().ResolveAlertResourceReference(ref)
}
// legacyDockerHistoryIdentity accepts the source identifier emitted by Docker
// alerts only when it contains a complete container ID. Names and short IDs
// cannot establish durable identity after inventory removal.
@@ -128,7 +198,7 @@ func historySubResourceOwner(ref string) (ownerRef string, ownerType ResourceTyp
if rest, found := strings.CutPrefix(ref, "agent:"); found {
hostID, child, _ := strings.Cut(rest, "/")
kind, label, _ := strings.Cut(child, ":")
if hostID == "" || label == "" {
if hostID == "" || label == "" || strings.ContainsAny(label, "/\\") {
return "", "", false
}
switch kind {
@@ -160,12 +230,19 @@ func historySubResourceOwner(ref string) (ownerRef string, ownerType ResourceTyp
// possibly ambiguously; an ambiguous or conflicting reference resolves to
// nothing and must not follow a retained binding.
func (rr *ResourceRegistry) resolveHistoryReference(ref string) (resourceID string, claimed bool) {
ref = CanonicalResourceID(ref)
if rr == nil || ref == "" {
if rr == nil {
return "", false
}
rr.mu.RLock()
defer rr.mu.RUnlock()
return rr.resolveHistoryReferenceLocked(ref)
}
func (rr *ResourceRegistry) resolveHistoryReferenceLocked(ref string) (resourceID string, claimed bool) {
ref = CanonicalResourceID(ref)
if ref == "" {
return "", false
}
if isDockerHistoryReference(ref) {
resourceID = rr.dockerHistoryOwnerLocked(ref)
return resourceID, resourceID != ""
@@ -181,7 +258,11 @@ func (rr *ResourceRegistry) resolveHistoryReference(ref string) (resourceID stri
// conflict: the event keeps its own reference.
owners := rr.durableHistoryMatchesLocked(ownerRef)
for id := range owners {
if len(owners) != 1 || CanonicalResourceType(rr.resources[id].Type) != ownerType {
owner := rr.resources[id]
// A linked agent retains ownership of its disk/sensor observations
// when its canonical resource is a VM, container or node.
typeMatches := CanonicalResourceType(owner.Type) == ownerType || (ownerType == ResourceTypeAgent && owner.Agent != nil)
if len(owners) != 1 || !typeMatches {
return "", true
}
matches[id] = struct{}{}
@@ -858,3 +858,53 @@ func TestProxmoxPhysicalDiskAlertReferenceShape(t *testing.T) {
require.Nil(t, ProxmoxPhysicalDiskAlertIdentifiers(ref), ref)
}
}
func TestAlertResourceReferenceRequiresCurrentDurableOwner(t *testing.T) {
snapshot := models.StateSnapshot{Hosts: []models.Host{{ID: "host-source", Hostname: "display-name", MachineID: "machine"}}}
registry := NewRegistry(nil)
registry.IngestSnapshot(snapshot)
_, ownerID, found := registry.GetByReference("host-source")
if !found {
t.Fatal("fixture owner missing")
}
for _, resolver := range []AlertResourceReferenceResolver{registry, NewUnifiedAIAdapter(registry), NewMonitorAdapter(registry)} {
binding, claimed := resolver.ResolveAlertResourceReference("agent:host-source/disk:docker-data")
if !claimed || binding.ResourceID != ownerID || binding.ResourceType != ResourceTypeAgent || len(binding.SourceTargets) == 0 {
t.Fatalf("lost canonical owner/source: %+v, claimed=%v", binding, claimed)
}
for _, ref := range []string{"agent:display-name/disk:docker-data", "agent:host-source/disk:", "agent:host-source/disk:data/nested", "agent:host-source/service:sshd", "agent:removed/disk:data"} {
if binding, _ := resolver.ResolveAlertResourceReference(ref); binding.ResourceID != "" {
t.Fatalf("bound untrusted reference %q: %+v", ref, binding)
}
}
}
// A fresh collection without the owner cannot borrow retained history.
removed := NewRegistry(nil)
if binding, _ := removed.ResolveAlertResourceReference("agent:host-source/disk:docker-data"); binding.ResourceID != "" {
t.Fatalf("removed owner resolved: %+v", binding)
}
}
func TestHistoryIdentityLinkedAgentDiskUsesCanonicalOwner(t *testing.T) {
store := NewMemoryStore()
snapshot := models.StateSnapshot{
Hosts: []models.Host{{ID: "host-source", MachineID: "machine"}},
VMs: []models.VM{{ID: "vm-source", VMID: 101, Instance: "pve", Node: "node"}},
}
registry := NewRegistry(store)
registry.IngestSnapshot(snapshot)
_, hostID, hostFound := registry.GetByReference("host-source")
_, vmID, vmFound := registry.GetByReference("vm-source")
require.True(t, hostFound)
require.True(t, vmFound)
require.NoError(t, store.AddLink(ResourceLink{ResourceA: hostID, ResourceB: vmID, PrimaryID: vmID}))
registry = NewRegistry(store)
registry.IngestSnapshot(snapshot)
ownerID, claimed := registry.resolveHistoryReference("agent:host-source/disk:data")
require.True(t, claimed)
require.Equal(t, vmID, ownerID)
for _, ref := range []string{"agent:host-source/disk:data/nested", "agent:missing/disk:data"} {
ownerID, _ := registry.resolveHistoryReference(ref)
require.Empty(t, ownerID, ref)
}
}
+120
View File
@@ -0,0 +1,120 @@
// Admission/UI contract proof with a production-emitted disk alert.
// GET estate/session data is synthetic. Patrol POSTs use the current Go handler
// from TestPatrolHostDiskBrowserBackend. No inference or infrastructure writes.
import { chromium, expect } from '../tests/integration/node_modules/@playwright/test/index.mjs';
import { mkdir, writeFile, readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
const base = process.env.PLAYWRIGHT_BASE_URL;
const backend = process.env.PULSE_HOST_DISK_BROWSER_URL;
if (!base || !backend) throw new Error('Set PLAYWRIGHT_BASE_URL and PULSE_HOST_DISK_BROWSER_URL to isolated loopback servers');
for (const url of [base, backend]) {
if (new URL(url).hostname !== '127.0.0.1') throw new Error('Proof servers must use loopback');
}
const output = new URL('../tmp/host-disk-patrol/', import.meta.url);
await mkdir(output, { recursive: true });
const browser = await chromium.launch({ headless: true });
const results = [];
const errors = [];
const diagnostics = [];
let lastPage;
try {
const request = await browser.newContext();
const alert = await (await request.request.get(`${backend}/proof/alert`)).json();
await request.close();
expect(alert.resourceId).toBe('agent:repro-host-source-01/disk:docker-data');
expect(alert.value).toBe(85);
expect(alert.threshold).toBe(85);
for (const width of [1440, 390]) {
const context = await browser.newContext({ viewport: { width, height: 1000 } });
const page = await context.newPage();
lastPage = page;
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') diagnostics.push(message.text());
});
page.on('requestfailed', (request) => diagnostics.push(`${request.url()}: ${request.failure()?.errorText}`));
let mode = 'accepted';
const admissions = [];
const state = { nodes: [], vms: [], containers: [], dockerHosts: [], hosts: [], storage: [], resources: [], activeAlerts: [alert], recentlyResolved: [], connectionHealth: {}, timestamp: Date.now() };
await page.routeWebSocket('**/ws**', (socket) => socket.send(JSON.stringify({ type: 'initialState', data: state })));
await page.route('**/api/**', async (route) => {
const req = route.request();
const path = new URL(req.url()).pathname;
// Vite also serves source modules below /src/api/. Only stub HTTP API
// endpoints; module requests must load the actual application source.
if (!path.startsWith('/api/')) return route.continue();
if (path === '/api/ai/patrol/run' && req.method() === 'POST') {
const body = req.postDataJSON();
expect(body).toEqual({ resource_ids: [alert.resourceId], alert_identifier: alert.id, alert_type: 'disk' });
const response = await route.fetch({ url: `${backend}${path}?case=${mode}` });
admissions.push({ mode, body, status: response.status(), response: await response.json() });
return route.fulfill({ response });
}
if (req.method() !== 'GET') return route.fulfill({ status: 403, json: { error: 'Other writes disabled in proof' } });
if (path === '/api/version') return route.fulfill({ json: { version: '6.5.0', build: 'proof', runtime: 'development', isDocker: false, isSourceBuild: true, isDevelopment: true } });
if (path === '/api/updates/check') return route.fulfill({ json: { available: false, currentVersion: '6.5.0', latestVersion: '6.5.0', releaseNotes: '', downloadUrl: '', isPrerelease: false, isMajorUpgrade: false } });
if (path === '/api/updates/status') return route.fulfill({ json: { status: 'idle', progress: 0, message: '', updatedAt: new Date().toISOString() } });
if (path === '/api/security/status') return route.fulfill({ json: { hasAuthentication: true, hasProxyAuth: true, proxyAuthUsername: 'proof-operator', sessionCapabilities: { assistantEnabled: true, settingsRead: true, settingsWrite: true } } });
if (path === '/api/license/runtime-capabilities') return route.fulfill({ json: { capabilities: ['ai_patrol', 'sso'], limits: [], blocked_capabilities: [], hosted_mode: false } });
if (path === '/api/orgs') return route.fulfill({ json: [{ id: 'default', displayName: 'Proof' }] });
if (path === '/api/resources') return route.fulfill({ json: { data: [], aggregations: { platformAdmission: { proxmox: false, docker: false, kubernetes: false, truenas: false, vmware: false, standalone: true } } } });
if (path === '/api/state') return route.fulfill({ json: state });
if (path === '/api/alerts/active') return route.fulfill({ json: [alert] });
if (path === '/api/alerts/config') return route.fulfill({ json: { enabled: true, activationState: 'active', nodeDefaults: {}, guestDefaults: {}, agentDefaults: {}, overrides: {}, timeThresholds: {}, notifications: {} } });
if (path === '/api/ai/settings') return route.fulfill({ json: { enabled: true, model: 'fixture:admission', control_level: 'read_only' } });
if (/\/(history|events|delivery-diagnosis|sessions|models|findings|incidents)$/.test(path)) return route.fulfill({ json: [] });
return route.fulfill({ json: {} });
});
await page.goto(`${base}/alerts`, { waitUntil: 'domcontentloaded' });
const button = page.getByTitle('Have Patrol investigate this alert', { exact: true });
await expect(button).toBeVisible({ timeout: 30000 });
await button.scrollIntoViewIfNeeded();
const rect = await button.boundingBox();
expect(rect.x).toBeGreaterThanOrEqual(0);
expect(rect.x + rect.width).toBeLessThanOrEqual(width);
await page.screenshot({ path: new URL(`default-${width}.png`, output).pathname, fullPage: true });
const menu = page.getByRole('button', { name: 'More alert actions', exact: true });
await menu.click();
await page.screenshot({ path: new URL(`menu-${width}.png`, output).pathname, fullPage: true });
await page.keyboard.press('Escape');
await expect(menu).toHaveAttribute('aria-expanded', 'false');
await menu.click();
await page.getByText('Active Alerts', { exact: true }).first().click();
await expect(menu).toHaveAttribute('aria-expanded', 'false');
for (const [scenario, status, text] of [
['accepted', 200, `Patrol is investigating ${alert.resourceName}`],
['busy', 409, 'Patrol is already running.'],
['unresolved', 422, 'One or more requested resource identities could not be resolved exactly'],
]) {
mode = scenario;
const before = admissions.length;
await button.focus();
await page.keyboard.press('Enter');
await expect.poll(() => admissions.length).toBe(before + 1);
expect(admissions.at(-1).status).toBe(status);
await expect(page.getByText(text, { exact: false }).first()).toBeVisible();
await page.screenshot({ path: new URL(`${scenario}-${width}.png`, output).pathname, fullPage: true });
}
mode = 'accepted';
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(button).toBeVisible();
await button.click();
await expect.poll(() => admissions.at(-1).mode).toBe('accepted');
await expect(page.getByText(`Patrol is investigating ${alert.resourceName}`, { exact: true })).toBeVisible();
results.push({ width, admissions });
await context.close();
}
expect(errors).toEqual([]);
const paths = ['internal/unifiedresources/history_identity.go', 'internal/ai/patrol_alert_scope.go', 'internal/ai/patrol_state.go', 'internal/ai/patrol_run.go', 'internal/ai/patrol_ai.go', 'internal/ai/patrol_triggers.go'];
const content_sha256 = Object.fromEntries(await Promise.all(paths.map(async (path) => [path, createHash('sha256').update(await readFile(new URL(`../${path}`, import.meta.url))).digest('hex')])));
await writeFile(new URL('result.json', output), JSON.stringify({ result: 'passed', verified_at: new Date().toISOString(), frontend_base_sha: process.env.PROOF_BASE_SHA, content_sha256, routes: ['/alerts'], viewports: [1440, 390], states: ['default', 'menu open/closed', 'accepted', 'busy', 'unresolved owner', 'reload'], interactions: ['pointer', 'Enter', 'Escape', 'outside click', 'reload then re-submit'], results, errors, limits: 'Real API admission, synthetic estate/session, no inference or diagnosis qualification' }, null, 2));
} catch (error) {
if (lastPage && !lastPage.isClosed()) {
await lastPage.screenshot({ path: new URL('failure.png', output).pathname, fullPage: true });
await writeFile(new URL('failure.txt', output), `${error.stack}\nPage errors: ${JSON.stringify(errors)}\nDiagnostics: ${JSON.stringify(diagnostics)}\nURL: ${lastPage.url()}\n${await lastPage.locator('body').innerText()}`);
}
throw error;
} finally {
await browser.close();
}