Separate command connectivity from monitoring evidence

Name command transport explicitly in shared query results and preserve
unobserved connection state in topology and Assistant inventory context.
Keep parent-node transport distinct from a direct guest connection so
monitoring evidence cannot imply command access or collection downtime.

Existing execution policy and approval checks remain authoritative.
This commit is contained in:
rcourtman
2026-09-06 14:10:11 +01:00
parent 4d302109ce
commit f5f440dbad
9 changed files with 455 additions and 163 deletions
@@ -1619,3 +1619,77 @@ so this assessment does not qualify model use of that corrected action.
Storage-fault ground truth, reliable diagnosis, approved/rejected actions and
independent recovery remain open. The supported autonomous provider dependency
and wider independent-Pro-environment gate remain unchanged.
### Command connection evidence correction plan
Live command connectivity and retained monitoring observations are independent
facts. The shared tool contract will name command-agent connections explicitly,
including parent-node connections, without changing routing or execution policy.
Topology built without a command-connection snapshot must omit connection flags,
execution hints and connected counts rather than manufacture false/zero values.
An observed empty snapshot still reports disconnected/zero. Assistant inventory
context must preserve the same observation boundary. Existing permission,
approval and invocation checks remain authoritative.
Regression matrix: current Docker inventory and metrics with disconnected and
connected command transport, read-only control with a connected agent, parent
node versus guest connection, topology without a connection observation versus
an observed empty set, and Assistant's seeded inventory. Run affected tools/chat
packages and focused race proof on pulse-dev.
Browser matrix after rebuilding the local Pro backend: `/patrol` at 1440x1000,
900x1000 and 390x1000, actual captured query results showing disconnected and
connected command transport beside unchanged monitored workload evidence.
Exercise tool details open/closed, keyboard focus/activation, deepest output
scrolling, Escape, reload and persisted result presentation. Inspect pixels and
bind receipts to the final source and binary. Controlled rendering proof does
not qualify model interpretation, autonomous Patrol or infrastructure actions.
### Command connection evidence qualification
The original projection failed the new regression because it labelled command
transport as generic agent connectivity and emitted connected-agent counts from
an inventory-only seed. Canonical guest search also promoted a parent-node
connection into a direct guest connection. The shared projection now retains
those distinctions. Existing host aliases remain available for non-guest
resources. No routing, approval, execution or provider policy boundary changes.
Four canonical query cases pass: no command connection, connected read-only
transport, a direct guest connection without a parent connection, and connected
transport with control enabled. Current workload state and CPU remain available
in every case and no command is executed. Separate checks prove that topology
without a command snapshot omits connection and execution hints and connected
counts, while an observed empty snapshot retains false/zero. Assistant inventory
context inherits that same unobserved state.
Final source proof on pulse-dev used Go1.26.8 and GOMAXPROCS4. The full tools
package passed in 59.456s and chat in 6.402s. Focused race checks passed in 1.048s
and 1.030s. The Pro runtime cross-build passed and the installed local binary
SHA256 is `bcaf748107211ee733a6dc0f4d17220d9b4d1ce1918c25bde27cf3d10c0d6379`.
The managed development process restarted onto that artifact and `/api/health`
reported healthy. No production agent was replaced.
Playwright exercised nine captured results at `/patrol`, 1440x1000, 900x1000
and 390x1000. Inputs, outputs and completed states match exactly before and after
controlled session reload. Hover, keyboard focus/activation, expansion/collapse,
deepest output scrolling, Escape and session selection passed. Pixel inspection
covered each distinct connection state, unchanged workload metrics and restored
mobile results. Backend and renderer hashes remained unchanged. The artificial
route-check warning and controlled persistence fixtures retain their earlier
qualification limits. No model request was part of this proof.
A read-only settings check confirms the cached `provider_refusal` still carries
its original `2026-09-05T19:46:39Z` timestamp and `patrol_capable=false`.
Private source bindings, logs, captured outputs, runtime process/health receipts
and browser proof are at workspace-relative `tmp/patrol-command-context/`.
The change still requires its scoped pre-commit and landing checks. The preceding
PR #1935 head `4d302109cee0758a132ff150935630b50114cc05` has no reported failures
but its Build and Test and Core E2E runs are pending behind live earlier runs
on the same branch. Those workflows are not restarted or cancelled.
This correction establishes the connection evidence contract, not reliable
interpretation. Native configuration-read model use, storage-fault diagnosis,
approved/rejected action outcomes and independent recovery remain open, as do
the supported autonomous provider dependency and independent-environment gate.
+1 -1
View File
@@ -10201,7 +10201,7 @@
},
{
"id": "patrol-assistant-customer-outcome-qualification",
"summary": "The explicit redesign plan and source-bound receipts remain in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. The goal is open. Model judgment owns diagnosis, with observations, hypotheses, proposals, executions and independently verified outcomes kept distinct. The recorded baseline has 127 paid installations, 71 with Patrol enabled, 23 with Assistant calls and fourteen verified resolutions from one installation. Schema17 outcome/provider/cost fields had no adoption. These are not representative success, false-alarm or missed-problem rates. Shared provenance, history, risk, missing-access and continuity corrections landed through PR1928/1929. Proposal-as-proof and proxy-driven diagnostic completion policy were removed. PR1934 merged the tool-ID and transcript-identity correction at 6b0abc3bee9ffa81f6ab298b5b67ee11369688a0 with all checks passing. Its captured-response browser replay preserves seven healthy and sixteen dependency tool records. The two ordinary subscription Assistant cases took 82.835s and 204.384s. Their primary decisions had useful evidence, but storage exclusion, recovery implications and config-not-found errors remain unqualified. Independent Docker fixtures prove injection, negative controls and deadline cleanup, not completed recovery or model/action competence. The current scoped Docker measurement correction preserves per-direction observation presence, separates corrected history from ambiguous legacy rows, removes image-layer ratios from capacity, and carries optional rates through resource/browser conversion. Legacy workload direction availability, Docker-host first-disk history and network presence remain distinct consumer/source follow-ups. The current configuration-read correction resolves identity and placement from canonical inventory, preserves explicit query restrictions, and distinguishes unavailable capability/provider observations from missing resources with the shared tool error bit. Fourteen contract cases, full tools package, targeted race checks and captured-result browser rendering pass. Both corrections are pushed to PR1935 at 355ac1f0a481d6dbc9a7ff3977bced0956711979 with exact worker hooks passing and remote checks pending. A fresh ordinary storage assessment took 177.869s and thirteen successful reads. It identified the actual capacity alert and preserved limited I/O history, but contradictory capacity assurance, unsupported artifact attribution and a misplaced CPU-peak time keep diagnosis unqualified. All thirteen rendered tool records match persistence. Desktop/mobile browser proof and its artificial route-warning limit are recorded in the plan. Command connection versus telemetry freshness and unsupported filter handling remain tool-context review items. Claude Max explicitly refused autonomous Patrol readiness. Cached refusal and API409 enforcement remain intact. Ordinary Assistant is not autonomous qualification. Separate paid-provider approval remains pending and no paid request occurred. Reliable interpretation, real-model retest of config reads, storage/backup and approved/rejected action outcomes remain required local work. Independent volunteered Pro environments remain a separate wider-readiness gate.",
"summary": "The explicit redesign plan and source-bound receipts remain in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. The goal is open. Model judgment owns diagnosis, with observations, hypotheses, proposals, executions and independently verified outcomes kept distinct. The recorded baseline has 127 paid installations, 71 with Patrol enabled, 23 with Assistant calls and fourteen verified resolutions from one installation. Schema17 outcome/provider/cost fields had no adoption. These are not representative success, false-alarm or missed-problem rates. Shared provenance, history, risk, missing-access and continuity corrections landed through PR1928/1929. Proposal-as-proof and proxy-driven diagnostic completion policy were removed. PR1934 merged the tool-ID and transcript-identity correction at 6b0abc3bee9ffa81f6ab298b5b67ee11369688a0 with all checks passing. Its captured-response browser replay preserves seven healthy and sixteen dependency tool records. The two ordinary subscription Assistant cases took 82.835s and 204.384s. Their primary decisions had useful evidence, but storage exclusion, recovery implications and config-not-found errors remain unqualified. Independent Docker fixtures prove injection, negative controls and deadline cleanup, not completed recovery or model/action competence. The current scoped Docker measurement correction preserves per-direction observation presence, separates corrected history from ambiguous legacy rows, removes image-layer ratios from capacity, and carries optional rates through resource/browser conversion. Legacy workload direction availability, Docker-host first-disk history and network presence remain distinct consumer/source follow-ups. The current configuration-read correction resolves identity and placement from canonical inventory, preserves explicit query restrictions, and distinguishes unavailable capability/provider observations from missing resources with the shared tool error bit. Fourteen contract cases, full tools package, targeted race checks and captured-result browser rendering pass. Both corrections are pushed to PR1935 at 355ac1f0a481d6dbc9a7ff3977bced0956711979 with exact worker hooks passing and remote checks pending. A fresh ordinary storage assessment took 177.869s and thirteen successful reads. It identified the actual capacity alert and preserved limited I/O history, but contradictory capacity assurance, unsupported artifact attribution and a misplaced CPU-peak time keep diagnosis unqualified. All thirteen rendered tool records match persistence. Desktop/mobile browser proof and its artificial route-warning limit are recorded in the plan. The current shared connection projection names command transport explicitly, omits unqueried connection and execution hints/counts from topology and Assistant inventory, and separates parent-node transport from direct guest connectivity. Four canonical connection/control cases, unobserved/empty topology, inventory context, full tools/chat packages, focused race checks and nine captured browser results pass. Scoped landing is pending. Unsupported filter handling remains a tool-context review item. Claude Max explicitly refused autonomous Patrol readiness. Cached refusal and API409 enforcement remain intact. Ordinary Assistant is not autonomous qualification. Separate paid-provider approval remains pending and no paid request occurred. Reliable interpretation, real-model retest of config reads, storage/backup and approved/rejected action outcomes remain required local work. Independent volunteered Pro environments remain a separate wider-readiness gate.",
"owner": "project-owner",
"status": "planned",
"recorded_at": "2026-09-05",
@@ -25,6 +25,22 @@ that same result. Successful reads retain their content and execution provenance
## Purpose
Shared query projections name command transport explicitly through
`command_agent_connected`, `node_command_agent_connected` and the corresponding
topology counts. These observations do not establish monitoring freshness or
installation state. A topology built without a connection snapshot omits command
flags, execution hints and connected counts. An observed empty snapshot preserves
false/zero. Assistant's inventory seed carries that same absence semantics.
The parent node's connection cannot become a direct guest connection merely
because provider placement names that node. Existing command routing, control,
approval and invocation enforcement remain authoritative. A `can_execute` hint
reflects connected transport with control enabled, not approval for an operation.
`TestCommandConnectivityDoesNotReplaceMonitoringEvidence`,
`TestTopologyOmitsUnobservedCommandConnections` and
`TestAssistantInventoryDoesNotInventCommandConnectionObservations` cover these
projection and continuity boundaries. Existing persisted tool records are not
rewritten, and this contract does not qualify model diagnosis or recovery.
Native app-container configuration reads resolve identity, provider and placement
from current canonical inventory. Optional session discovery cannot fabricate a
not-found result or replace current placement with a stale execution target.
+12 -10
View File
@@ -1,14 +1,16 @@
{
"version": 1,
"base_sha": "0fcb2ee147354de770dfc4b0b9672d8c2c9dceb2",
"verified_at": "2026-09-06T12:13:06.548Z",
"base_sha": "4d302109cee0758a132ff150935630b50114cc05",
"verified_at": "2026-09-06T12:57:13.096Z",
"result": "passed",
"changed_paths": [],
"content_sha256": {},
"backend_content_sha256": {
"internal/ai/tools/tools_query.go": "e9411606cdf96dec84882d0649ee69fb935b36086f0b5682d98fe3ee184f6841"
"internal/ai/tools/data_types.go": "00200b1366ee4bf1e541e3acf6618f56993b05b05fa08122b91cd2db320969ec",
"internal/ai/tools/tools_query.go": "69bd79091bc8892043b900ca8475d1ee9d25c9613cf0ca86e36e41a0e468e2f8",
"internal/ai/chat/service.go": "7b4a7696eb0a3ca2a99427d98f991740ff362bfc0c738c36f45f80549d7c2744"
},
"binary_sha256": "552699cdf2e61a4ca1cea2ac5ef4e065735cbd1dbca01665456e184bd4fc3533",
"binary_sha256": "bcaf748107211ee733a6dc0f4d17220d9b4d1ce1918c25bde27cf3d10c0d6379",
"rendering_content_sha256": {
"frontend-modern/src/components/AI/Chat/hooks/useChat.ts": "0b56b7a56e35d51ca96f0e126dd493b3164aa9e0ad4d8ae24bcf3af7a574b97c",
"frontend-modern/src/components/AI/Chat/ChatMessages.tsx": "9672f7608d1e3a531c73cba20fd4a78752316783212afd0c292ddfd11d2bf371",
@@ -32,13 +34,13 @@
}
],
"states": [
"Actual configuration tool results captured from the final Go regression run: available config without session context, unsupported adapter, actual missing inventory resource, explicit query denial, nil provider response, provider failure, incomplete placement and unavailable inventory. This backend-only change preserves the tool error bit and exact known resource identity.",
"Eight captured results replayed through the current Assistant stream renderer and restored through controlled session responses after a full page reload. This is rendering and reload proof, not server persistence, native provider integration, model diagnosis or action qualification.",
"The local Pro backend was rebuilt on pulse-dev with Go1.26.8. Binary and source hashes matched before and after Playwright. Non-GET provider and infrastructure requests were blocked. The route warning in this controlled browser does not qualify provider readiness."
"Nine actual regression outputs: disconnected transport beside current workload metrics, connected transport under read-only control, distinct parent and guest connections, control-enabled transport, unqueried topology and an observed empty connection snapshot.",
"Each captured input and output is preserved by the Assistant stream renderer and restored through controlled session responses after reload. Unobserved fields remain absent and observed false/zero remains explicit. This is rendering proof, not native integration or model diagnosis.",
"Local Pro backend cross-built on pulse-dev with Go1.26.8. Source and binary hashes matched before and after Playwright. Browser interception blocks provider and infrastructure writes. Its selected-route warning does not qualify provider readiness."
],
"interactions": [
"Tool details open/closed, hover, keyboard focus, Enter and Space, complete input/output comparison, deepest output scrolling and pixel inspection at desktop, intermediate and narrow widths. Successful reads show completed and unavailable or denied reads show failed.",
"Escape, full reload, session picker selection, reopening each restored result and exact output comparison with preserved success/error state. No model call or infrastructure action was attempted.",
"Private final-source evidence: /Volumes/Development/pulse/tmp/patrol-config-read-contract/browser, tool-evidence.json and runtime-binding.json."
"Tool details open/closed, hover, keyboard focus, Enter and Space, exact complete input/output comparison, deepest output scrolling and pixel inspection at desktop, intermediate and narrow widths.",
"Escape, full reload, session picker selection, reopening all nine restored tool records with exact output and completed-state assertions. Controlled session fixtures do not qualify server persistence.",
"Private evidence: /Volumes/Development/pulse/tmp/patrol-command-context/browser, tool-evidence.json, runtime-binding.json and final.log."
]
}
+34 -34
View File
@@ -1418,15 +1418,15 @@ func marshalAssistantInventoryTopologyContext(topology tools.TopologyResponse) (
}
for _, node := range topology.Proxmox.Nodes {
nodeContext := assistantInventoryProxmoxNode{
AnswerLabel: assistantInventoryNodeAnswerLabel(node.Name),
Name: node.Name,
Status: node.Status,
AgentConnected: node.AgentConnected,
CanExecute: node.CanExecute,
VMCount: node.VMCount,
ContainerCount: node.ContainerCount,
VMs: make([]assistantInventoryWorkload, 0, len(node.VMs)),
Containers: make([]assistantInventoryWorkload, 0, len(node.Containers)),
AnswerLabel: assistantInventoryNodeAnswerLabel(node.Name),
Name: node.Name,
Status: node.Status,
CommandAgentConnected: node.CommandAgentConnected,
CanExecute: node.CanExecute,
VMCount: node.VMCount,
ContainerCount: node.ContainerCount,
VMs: make([]assistantInventoryWorkload, 0, len(node.VMs)),
Containers: make([]assistantInventoryWorkload, 0, len(node.Containers)),
}
for _, vm := range node.VMs {
nodeContext.VMs = append(nodeContext.VMs, assistantInventoryWorkload{
@@ -1452,14 +1452,14 @@ func marshalAssistantInventoryTopologyContext(topology tools.TopologyResponse) (
}
for _, host := range topology.Docker.Hosts {
hostContext := assistantInventoryDockerHost{
AnswerLabel: firstNonEmptyString(host.DisplayName, host.Hostname),
Hostname: host.Hostname,
DisplayName: host.DisplayName,
AgentConnected: host.AgentConnected,
CanExecute: host.CanExecute,
ContainerCount: host.ContainerCount,
RunningCount: host.RunningCount,
Containers: make([]assistantInventoryAppContainer, 0, len(host.Containers)),
AnswerLabel: firstNonEmptyString(host.DisplayName, host.Hostname),
Hostname: host.Hostname,
DisplayName: host.DisplayName,
CommandAgentConnected: host.CommandAgentConnected,
CanExecute: host.CanExecute,
ContainerCount: host.ContainerCount,
RunningCount: host.RunningCount,
Containers: make([]assistantInventoryAppContainer, 0, len(host.Containers)),
}
for _, container := range host.Containers {
hostContext.Containers = append(hostContext.Containers, assistantInventoryAppContainer{
@@ -1547,15 +1547,15 @@ type assistantInventoryKubernetesTopology struct {
}
type assistantInventoryProxmoxNode struct {
AnswerLabel string `json:"answer_label"`
Name string `json:"name"`
Status string `json:"status"`
AgentConnected bool `json:"agent_connected,omitempty"`
CanExecute bool `json:"can_execute,omitempty"`
VMCount int `json:"vm_count"`
ContainerCount int `json:"container_count"`
VMs []assistantInventoryWorkload `json:"vms"`
Containers []assistantInventoryWorkload `json:"containers"`
AnswerLabel string `json:"answer_label"`
Name string `json:"name"`
Status string `json:"status"`
CommandAgentConnected *bool `json:"command_agent_connected,omitempty"`
CanExecute *bool `json:"can_execute,omitempty"`
VMCount int `json:"vm_count"`
ContainerCount int `json:"container_count"`
VMs []assistantInventoryWorkload `json:"vms"`
Containers []assistantInventoryWorkload `json:"containers"`
}
type assistantInventoryWorkload struct {
@@ -1568,14 +1568,14 @@ type assistantInventoryWorkload struct {
}
type assistantInventoryDockerHost struct {
AnswerLabel string `json:"answer_label"`
Hostname string `json:"hostname"`
DisplayName string `json:"display_name,omitempty"`
AgentConnected bool `json:"agent_connected,omitempty"`
CanExecute bool `json:"can_execute,omitempty"`
ContainerCount int `json:"container_count"`
RunningCount int `json:"running_count"`
Containers []assistantInventoryAppContainer `json:"containers"`
AnswerLabel string `json:"answer_label"`
Hostname string `json:"hostname"`
DisplayName string `json:"display_name,omitempty"`
CommandAgentConnected *bool `json:"command_agent_connected,omitempty"`
CanExecute *bool `json:"can_execute,omitempty"`
ContainerCount int `json:"container_count"`
RunningCount int `json:"running_count"`
Containers []assistantInventoryAppContainer `json:"containers"`
}
type assistantInventoryAppContainer struct {
@@ -0,0 +1,35 @@
package chat
import (
"encoding/json"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
func TestAssistantInventoryDoesNotInventCommandConnectionObservations(t *testing.T) {
registry := unifiedresources.NewRegistry(nil)
registry.IngestSnapshot(models.StateSnapshot{
Nodes: []models.Node{{ID: "node-one", Name: "node-one", Status: "online"}},
DockerHosts: []models.DockerHost{{ID: "host-one", Hostname: "observed-host", Status: "online", Containers: []models.DockerContainer{{ID: "app-one", Name: "observed-app", State: "running"}}}},
})
raw, err := marshalAssistantInventoryTopologyContextFromReadState(registry)
if err != nil {
t.Fatal(err)
}
for _, field := range []string{"agent_connected", "command_agent_connected", "can_execute", "nodes_with_agents", "docker_hosts_with_agents", "nodes_with_command_agents", "docker_hosts_with_command_agents"} {
if strings.Contains(raw, `"`+field+`"`) {
t.Fatalf("inventory seed invented %s without observing command connections: %s", field, raw)
}
}
var decoded map[string]any
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
t.Fatal(err)
}
host := decoded["docker"].(map[string]any)["hosts"].([]any)[0].(map[string]any)
if host["hostname"] != "observed-host" || host["container_count"] != float64(1) {
t.Fatalf("monitoring inventory was lost: %+v", host)
}
}
@@ -0,0 +1,145 @@
package tools
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
func commandEvidenceSnapshot() models.StateSnapshot {
return models.StateSnapshot{
Nodes: []models.Node{{ID: "node-one", Name: "node-one", Status: "online"}},
VMs: []models.VM{{ID: "vm-one", VMID: 101, Name: "guest-one", Node: "node-one", Status: "running"}},
DockerHosts: []models.DockerHost{{
ID: "host-one", Hostname: "command-host", Status: "online", LastSeen: time.Now(),
Containers: []models.DockerContainer{{ID: "container-one", Name: "observed-service", State: "running", CPUPercent: 12.5}},
}},
}
}
func commandEvidenceJSON(t *testing.T, value any) map[string]any {
t.Helper()
raw, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
var result map[string]any
if err := json.Unmarshal(raw, &result); err != nil {
t.Fatal(err)
}
return result
}
func TestCommandConnectivityDoesNotReplaceMonitoringEvidence(t *testing.T) {
for _, tc := range []struct {
name string
connected, guestConnected, controlEnabled bool
}{
{name: "no_command_connection"},
{name: "connected_read_only", connected: true},
{name: "guest_connection_only", guestConnected: true},
{name: "connected_control_enabled", connected: true, controlEnabled: true},
} {
t.Run(tc.name, func(t *testing.T) {
server := &mockAgentServer{}
if tc.connected {
server.agents = []agentexec.ConnectedAgent{{Hostname: "command-host"}, {Hostname: "node-one"}}
}
if tc.guestConnected {
server.agents = append(server.agents, agentexec.ConnectedAgent{Hostname: "guest-one"})
}
controlLevel := ControlLevelReadOnly
if tc.controlEnabled {
controlLevel = ControlLevelControlled
}
registry := unifiedresources.NewRegistry(nil)
registry.IngestSnapshot(commandEvidenceSnapshot())
executor := NewPulseToolExecutor(ExecutorConfig{ReadState: registry, UnifiedResourceProvider: &registryUnifiedQueryProvider{registry}, AgentServer: server, ControlLevel: controlLevel})
query := func(args map[string]interface{}) map[string]any {
t.Helper()
result, err := executor.executeQuery(context.Background(), args)
if err != nil || result.IsError {
t.Fatalf("query failed: %v %+v", err, result)
}
var decoded map[string]any
if err := json.Unmarshal([]byte(result.Content[0].Text), &decoded); err != nil {
t.Fatal(err)
}
capture, _ := json.Marshal(map[string]any{"case": tc.name, "input": args, "output": decoded})
t.Logf("COMMAND_EVIDENCE %s", capture)
return decoded
}
list := query(map[string]interface{}{"action": "list", "type": "docker-hosts"})
host := list["docker_hosts"].([]any)[0].(map[string]any)
if host["command_agent_connected"] != tc.connected {
t.Fatalf("connection must name command transport: %+v", host)
}
if _, exists := host["agent_connected"]; exists {
t.Fatal("ambiguous connection field remains")
}
container := host["containers"].([]any)[0].(map[string]any)
resource := query(map[string]interface{}{"action": "get", "resource_type": "app-container", "resource_id": container["id"]})
if resource["status"] != "running" || resource["cpu"].(map[string]any)["percent"] != 12.5 {
t.Fatalf("command state replaced monitored evidence: %+v", resource)
}
topology := query(map[string]interface{}{"action": "topology", "include": "all"})
docker := topology["docker"].(map[string]any)["hosts"].([]any)[0].(map[string]any)
if docker["command_agent_connected"] != tc.connected || docker["can_execute"] != (tc.connected && tc.controlEnabled) {
t.Fatalf("transport/control hint changed: %+v", docker)
}
search := query(map[string]interface{}{"action": "search", "query": "guest-one"})
guest := search["matches"].([]any)[0].(map[string]any)
if guest["node_command_agent_connected"] != tc.connected {
t.Fatalf("parent transport not identified: %+v", guest)
}
if guest["command_agent_connected"] != tc.guestConnected {
t.Fatalf("parent connection became a direct guest connection: %+v", guest)
}
server.AssertNotCalled(t, "ExecuteCommand")
})
}
}
func TestTopologyOmitsUnobservedCommandConnections(t *testing.T) {
registry := unifiedresources.NewRegistry(nil)
registry.IngestSnapshot(commandEvidenceSnapshot())
for _, observed := range []bool{false, true} {
options := TopologyBuildOptions{Include: "all", ControlEnabled: true}
if observed {
options.ConnectedAgentHostnames = map[string]bool{}
}
result := commandEvidenceJSON(t, BuildTopologyResponseFromReadState(registry, options))
caseName := "unobserved_topology"
if observed {
caseName = "observed_empty_topology"
}
capture, err := json.Marshal(map[string]any{"case": caseName, "input": map[string]any{"action": "topology", "include": "all"}, "output": result})
if err != nil {
t.Fatal(err)
}
t.Logf("COMMAND_EVIDENCE %s", capture)
for _, group := range []struct{ family, collection string }{{"docker", "hosts"}, {"proxmox", "nodes"}} {
item := result[group.family].(map[string]any)[group.collection].([]any)[0].(map[string]any)
for _, field := range []string{"command_agent_connected", "can_execute"} {
value, exists := item[field]
if exists != observed || (exists && value != false) {
t.Fatalf("observed=%t field=%s: %+v", observed, field, item)
}
}
if _, exists := item["agent_connected"]; exists {
t.Fatal("ambiguous connection field remains")
}
}
for _, field := range []string{"nodes_with_command_agents", "docker_hosts_with_command_agents"} {
value, exists := result["summary"].(map[string]any)[field]
if exists != observed || (exists && value != float64(0)) {
t.Fatalf("unobserved connections became a count: %+v", result["summary"])
}
}
}
}
+63 -63
View File
@@ -238,40 +238,40 @@ func (r ResourceSearchResponse) NormalizeCollections() ResourceSearchResponse {
// ResourceMatch is a compact match result for pulse_search_resources
type ResourceMatch struct {
GovernedResourceMetadata
Type string `json:"type"` // "agent", "node", "vm", "system-container", "app-container", "docker-host", "storage"
ID string `json:"id,omitempty"`
Name string `json:"name"`
Status string `json:"status,omitempty"`
Node string `json:"node,omitempty"` // Hypervisor node this resource is on
NodeHasAgent bool `json:"node_has_agent,omitempty"` // True if the node has a connected agent
Host string `json:"host,omitempty"` // Docker host for docker containers
Platform string `json:"platform,omitempty"`
VMID int `json:"vmid,omitempty"`
Image string `json:"image,omitempty"`
AgentConnected bool `json:"agent_connected,omitempty"` // True if this specific resource has a connected agent
Type string `json:"type"` // "agent", "node", "vm", "system-container", "app-container", "docker-host", "storage"
ID string `json:"id,omitempty"`
Name string `json:"name"`
Status string `json:"status,omitempty"`
Node string `json:"node,omitempty"` // Hypervisor node this resource is on
NodeCommandAgentConnected *bool `json:"node_command_agent_connected,omitempty"` // Live command connection on the parent node, independent of telemetry collection
Host string `json:"host,omitempty"` // Docker host for docker containers
Platform string `json:"platform,omitempty"`
VMID int `json:"vmid,omitempty"`
Image string `json:"image,omitempty"`
CommandAgentConnected *bool `json:"command_agent_connected,omitempty"` // Live command connection for this resource, independent of telemetry collection
}
// SystemSummary is a summarized infrastructure system for list responses.
type SystemSummary struct {
GovernedResourceMetadata
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Platform string `json:"platform,omitempty"`
ChildCount int `json:"child_count,omitempty"`
AgentConnected bool `json:"agent_connected,omitempty"`
CPU float64 `json:"cpu_percent,omitempty"`
Memory float64 `json:"memory_percent,omitempty"`
Disk float64 `json:"disk_percent,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Platform string `json:"platform,omitempty"`
ChildCount int `json:"child_count,omitempty"`
CommandAgentConnected *bool `json:"command_agent_connected,omitempty"`
CPU float64 `json:"cpu_percent,omitempty"`
Memory float64 `json:"memory_percent,omitempty"`
Disk float64 `json:"disk_percent,omitempty"`
}
// NodeSummary is a summarized node for list responses
type NodeSummary struct {
GovernedResourceMetadata
Name string `json:"name"`
Status string `json:"status"`
ID string `json:"id,omitempty"`
AgentConnected bool `json:"agent_connected"` // True if an execution agent is connected for this node
Name string `json:"name"`
Status string `json:"status"`
ID string `json:"id,omitempty"`
CommandAgentConnected *bool `json:"command_agent_connected,omitempty"` // True if an execution agent is connected for this node
}
// VMSummary is a summarized VM for list responses
@@ -299,12 +299,12 @@ type ContainerSummary struct {
// DockerHostSummary is a summarized Docker host for list responses
type DockerHostSummary struct {
GovernedResourceMetadata
ID string `json:"id"`
Hostname string `json:"hostname"`
DisplayName string `json:"display_name,omitempty"`
ContainerCount int `json:"container_count"`
AgentConnected bool `json:"agent_connected"` // True if an execution agent is connected for this host
Containers []DockerContainerSummary `json:"containers"`
ID string `json:"id"`
Hostname string `json:"hostname"`
DisplayName string `json:"display_name,omitempty"`
ContainerCount int `json:"container_count"`
CommandAgentConnected *bool `json:"command_agent_connected,omitempty"` // True if an execution agent is connected for this host
Containers []DockerContainerSummary `json:"containers"`
}
func (s DockerHostSummary) NormalizeCollections() DockerHostSummary {
@@ -454,15 +454,15 @@ func (t ProxmoxTopology) NormalizeCollections() ProxmoxTopology {
// ProxmoxNodeTopology represents a Proxmox node with its guests
type ProxmoxNodeTopology struct {
GovernedResourceMetadata
Name string `json:"name"`
ID string `json:"id,omitempty"`
Status string `json:"status"`
AgentConnected bool `json:"agent_connected"`
CanExecute bool `json:"can_execute"` // True if commands can be executed on this node
VMs []TopologyVM `json:"vms"`
Containers []TopologyContainer `json:"containers"`
VMCount int `json:"vm_count"`
ContainerCount int `json:"container_count"`
Name string `json:"name"`
ID string `json:"id,omitempty"`
Status string `json:"status"`
CommandAgentConnected *bool `json:"command_agent_connected,omitempty"`
CanExecute *bool `json:"can_execute,omitempty"` // True if commands can be executed on this node
VMs []TopologyVM `json:"vms"`
Containers []TopologyContainer `json:"containers"`
VMCount int `json:"vm_count"`
ContainerCount int `json:"container_count"`
}
func (t ProxmoxNodeTopology) NormalizeCollections() ProxmoxNodeTopology {
@@ -538,15 +538,15 @@ func (t DockerTopology) NormalizeCollections() DockerTopology {
// DockerHostTopology represents a Docker host with its containers
type DockerHostTopology struct {
GovernedResourceMetadata
Hostname string `json:"hostname"`
DisplayName string `json:"display_name,omitempty"`
AgentConnected bool `json:"agent_connected"`
CanExecute bool `json:"can_execute"` // True if commands can be executed on this host
Containers []DockerContainerSummary `json:"containers"`
ContainerCount int `json:"container_count"`
ReturnedCount int `json:"returned_container_count"`
Truncated bool `json:"containers_truncated"`
RunningCount int `json:"running_count"`
Hostname string `json:"hostname"`
DisplayName string `json:"display_name,omitempty"`
CommandAgentConnected *bool `json:"command_agent_connected,omitempty"`
CanExecute *bool `json:"can_execute,omitempty"` // True if commands can be executed on this host
Containers []DockerContainerSummary `json:"containers"`
ContainerCount int `json:"container_count"`
ReturnedCount int `json:"returned_container_count"`
Truncated bool `json:"containers_truncated"`
RunningCount int `json:"running_count"`
}
func (t DockerHostTopology) NormalizeCollections() DockerHostTopology {
@@ -642,21 +642,21 @@ type KubernetesPodDetail struct {
// TopologySummary provides aggregate counts and status
type TopologySummary struct {
TotalNodes int `json:"total_nodes"`
TotalVMs int `json:"total_vms"`
TotalSystemContainers int `json:"total_system_containers"`
TotalDockerHosts int `json:"total_docker_hosts"`
TotalDockerContainers int `json:"total_docker_containers"`
TotalK8sClusters int `json:"total_k8s_clusters"`
TotalK8sNodes int `json:"total_k8s_nodes"`
TotalK8sDeployments int `json:"total_k8s_deployments"`
TotalK8sPods int `json:"total_k8s_pods"`
NodesWithAgents int `json:"nodes_with_agents"`
DockerHostsWithAgents int `json:"docker_hosts_with_agents"`
RunningVMs int `json:"running_vms"`
RunningContainers int `json:"running_containers"`
RunningDocker int `json:"running_docker"`
RunningK8sPods int `json:"running_k8s_pods"`
TotalNodes int `json:"total_nodes"`
TotalVMs int `json:"total_vms"`
TotalSystemContainers int `json:"total_system_containers"`
TotalDockerHosts int `json:"total_docker_hosts"`
TotalDockerContainers int `json:"total_docker_containers"`
TotalK8sClusters int `json:"total_k8s_clusters"`
TotalK8sNodes int `json:"total_k8s_nodes"`
TotalK8sDeployments int `json:"total_k8s_deployments"`
TotalK8sPods int `json:"total_k8s_pods"`
NodesWithCommandAgents *int `json:"nodes_with_command_agents,omitempty"`
DockerHostsWithCommandAgents *int `json:"docker_hosts_with_command_agents,omitempty"`
RunningVMs int `json:"running_vms"`
RunningContainers int `json:"running_containers"`
RunningDocker int `json:"running_docker"`
RunningK8sPods int `json:"running_k8s_pods"`
}
// ResourceResponse is returned by pulse_get_resource
+75 -55
View File
@@ -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. Health returns the connection overview by default, or the canonical resource projection when resource_id is provided.`,
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. command_agent_connected describes live command transport, independently of monitoring collection or freshness. Missing connection fields were not observed. can_execute describes connected transport with control enabled, not approval for a particular operation.`,
InputSchema: InputSchema{
Type: "object",
Properties: map[string]PropertySchema{
@@ -2463,17 +2463,35 @@ func resourceHostCandidates(resource unifiedresources.Resource) []string {
return candidates
}
func resourceAgentConnected(resource unifiedresources.Resource, connected map[string]bool) bool {
for _, candidate := range resourceHostCandidates(resource) {
key := strings.TrimSpace(candidate)
if key == "" {
continue
}
if connected[key] {
return true
// commandConnectionObservation keeps an unqueried snapshot distinct from an
// observed disconnected transport. It says nothing about telemetry freshness.
func commandConnectionObservation(snapshot map[string]bool, value bool) *bool {
if snapshot == nil {
return nil
}
return &value
}
func resourceCommandAgentConnected(resource unifiedresources.Resource, connected map[string]bool) *bool {
// A guest's provider node/host identifies placement, not a command
// connection inside the guest. Parent transport is projected separately.
candidates := []string{resourceDisplayName(resource)}
candidates = append(candidates, resource.Identity.Hostnames...)
if resource.Agent != nil {
candidates = append(candidates, resource.Agent.Hostname)
}
switch resource.Type {
case unifiedresources.ResourceTypeVM, unifiedresources.ResourceTypeSystemContainer, unifiedresources.ResourceTypeAppContainer:
// Only the guest's own identity can establish its direct connection.
default:
candidates = append(candidates, resourceHostCandidates(resource)...)
}
for _, candidate := range candidates {
if key := strings.TrimSpace(candidate); key != "" && connected[key] {
return commandConnectionObservation(connected, true)
}
}
return false
return commandConnectionObservation(connected, false)
}
func appContainerProviderID(resource unifiedresources.Resource) string {
@@ -2972,16 +2990,16 @@ func addCanonicalGuestSearchMatches(
node := canonicalGuestTarget(resource)
metadataCandidates := append([]string{resourceDisplayName(resource), resource.ID}, candidates...)
addMatch(ResourceMatch{
GovernedResourceMetadata: governance.Resolve(metadataCandidates...),
Type: kind,
ID: resource.ID,
Name: resourceDisplayName(resource),
Status: status,
Node: node,
NodeHasAgent: connectedAgentHostnames[node],
Platform: canonicalResourcePlatform(resource),
VMID: vmid,
AgentConnected: resourceAgentConnected(resource, connectedAgentHostnames),
GovernedResourceMetadata: governance.Resolve(metadataCandidates...),
Type: kind,
ID: resource.ID,
Name: resourceDisplayName(resource),
Status: status,
Node: node,
NodeCommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, connectedAgentHostnames[node]),
Platform: canonicalResourcePlatform(resource),
VMID: vmid,
CommandAgentConnected: resourceCommandAgentConnected(resource, connectedAgentHostnames),
})
}
}
@@ -3012,16 +3030,16 @@ func addGuestViewSearchMatches[V queryGuestView](
continue
}
addMatch(ResourceMatch{
GovernedResourceMetadata: governance.Resolve(g.Name(), g.ID(), vmidStr),
Type: kind,
ID: g.ID(),
Name: g.Name(),
Status: status,
Node: g.Node(),
NodeHasAgent: connectedAgentHostnames[g.Node()],
Platform: "proxmox",
VMID: g.VMID(),
AgentConnected: connectedAgentHostnames[g.Name()],
GovernedResourceMetadata: governance.Resolve(g.Name(), g.ID(), vmidStr),
Type: kind,
ID: g.ID(),
Name: g.Name(),
Status: status,
Node: g.Node(),
NodeCommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, connectedAgentHostnames[g.Node()]),
Platform: "proxmox",
VMID: g.VMID(),
CommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, connectedAgentHostnames[g.Name()]),
})
}
}
@@ -3469,15 +3487,15 @@ func resolvedAppContainerRegistration(resource unifiedresources.Resource) (Resou
func canonicalSystemSummaryFromResource(resource unifiedresources.Resource, connected map[string]bool) SystemSummary {
return SystemSummary{
ID: strings.TrimSpace(resource.ID),
Name: resourceDisplayName(resource),
Status: string(resource.Status),
Platform: canonicalResourcePlatform(resource),
ChildCount: resource.ChildCount,
AgentConnected: resourceAgentConnected(resource, connected),
CPU: metricPercent(resourceMetric(resource, "cpu")),
Memory: metricPercent(resourceMetric(resource, "memory")),
Disk: metricPercent(resourceMetric(resource, "disk")),
ID: strings.TrimSpace(resource.ID),
Name: resourceDisplayName(resource),
Status: string(resource.Status),
Platform: canonicalResourcePlatform(resource),
ChildCount: resource.ChildCount,
CommandAgentConnected: resourceCommandAgentConnected(resource, connected),
CPU: metricPercent(resourceMetric(resource, "cpu")),
Memory: metricPercent(resourceMetric(resource, "memory")),
Disk: metricPercent(resourceMetric(resource, "disk")),
}
}
@@ -3809,7 +3827,7 @@ func (e *PulseToolExecutor) executeListInfrastructure(_ context.Context, args ma
GovernedResourceMetadata: governance.Resolve(node.Name(), node.ID()),
Name: node.Name(),
Status: string(node.Status()),
AgentConnected: connectedAgentHostnames[node.Name()],
CommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, connectedAgentHostnames[node.Name()]),
})
count++
}
@@ -3993,7 +4011,7 @@ func (e *PulseToolExecutor) executeListInfrastructure(_ context.Context, args ma
Hostname: hostname,
DisplayName: displayName,
ContainerCount: len(hostContainers),
AgentConnected: connectedAgentHostnames[hostname] || connectedAgentHostnames[displayName],
CommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, connectedAgentHostnames[hostname] || connectedAgentHostnames[displayName]),
}
for _, container := range hostContainers {
state := strings.TrimSpace(container.ContainerState())
@@ -4248,7 +4266,7 @@ type TopologyBuildOptions struct {
MaxK8sNodesPerCluster int
MaxK8sDeploymentsPerCluster int
MaxK8sPodsPerCluster int
ConnectedAgentHostnames map[string]bool
ConnectedAgentHostnames map[string]bool // nil means command connections were not observed
ControlEnabled bool
}
@@ -4266,9 +4284,6 @@ func BuildTopologyResponseFromReadState(rs unifiedresources.ReadState, options T
includeDocker := include == "all" || include == "app-containers"
includeKubernetes := include == "all" || include == "kubernetes"
connectedAgentHostnames := options.ConnectedAgentHostnames
if connectedAgentHostnames == nil {
connectedAgentHostnames = map[string]bool{}
}
governance := newGovernedQueryMetadataResolver(rs)
summary := TopologySummary{
@@ -4283,12 +4298,17 @@ func BuildTopologyResponseFromReadState(rs unifiedresources.ReadState, options T
TotalK8sPods: len(rs.Pods()),
}
if connectedAgentHostnames != nil {
summary.NodesWithCommandAgents = new(int)
summary.DockerHostsWithCommandAgents = new(int)
}
for _, node := range rs.Nodes() {
if node == nil {
continue
}
if connectedAgentHostnames[node.Name()] {
summary.NodesWithAgents++
(*summary.NodesWithCommandAgents)++
}
}
for _, host := range rs.DockerHosts() {
@@ -4298,7 +4318,7 @@ func BuildTopologyResponseFromReadState(rs unifiedresources.ReadState, options T
hostname := strings.TrimSpace(host.Hostname())
displayName := strings.TrimSpace(host.Name())
if connectedAgentHostnames[hostname] || connectedAgentHostnames[displayName] {
summary.DockerHostsWithAgents++
(*summary.DockerHostsWithCommandAgents)++
}
}
for _, pod := range rs.Pods() {
@@ -4325,8 +4345,8 @@ func BuildTopologyResponseFromReadState(rs unifiedresources.ReadState, options T
GovernedResourceMetadata: governance.Resolve(node.Name(), node.ID()),
Name: name,
Status: string(node.Status()),
AgentConnected: hasAgent,
CanExecute: hasAgent && options.ControlEnabled,
CommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, hasAgent),
CanExecute: commandConnectionObservation(connectedAgentHostnames, hasAgent && options.ControlEnabled),
VMs: []TopologyVM{},
Containers: []TopologyContainer{},
}
@@ -4348,8 +4368,8 @@ func BuildTopologyResponseFromReadState(rs unifiedresources.ReadState, options T
GovernedResourceMetadata: governance.Resolve(name),
Name: name,
Status: status,
AgentConnected: hasAgent,
CanExecute: hasAgent && options.ControlEnabled,
CommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, hasAgent),
CanExecute: commandConnectionObservation(connectedAgentHostnames, hasAgent && options.ControlEnabled),
VMs: []TopologyVM{},
Containers: []TopologyContainer{},
}
@@ -4489,8 +4509,8 @@ func BuildTopologyResponseFromReadState(rs unifiedresources.ReadState, options T
GovernedResourceMetadata: governance.Resolve(host.Hostname(), host.Name(), host.HostSourceID(), host.ID()),
Hostname: hostname,
DisplayName: displayName,
AgentConnected: hasAgent,
CanExecute: hasAgent && options.ControlEnabled,
CommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, hasAgent),
CanExecute: commandConnectionObservation(connectedAgentHostnames, hasAgent && options.ControlEnabled),
Containers: containers,
ContainerCount: len(hostContainers),
ReturnedCount: len(containers),
@@ -5721,7 +5741,7 @@ func (e *PulseToolExecutor) executeSearchResources(_ context.Context, args map[s
Status: status,
Host: canonicalAgentHost(resource),
Platform: canonicalResourcePlatform(resource),
AgentConnected: resourceAgentConnected(resource, connectedAgentHostnames),
CommandAgentConnected: resourceCommandAgentConnected(resource, connectedAgentHostnames),
})
}
}
@@ -5740,7 +5760,7 @@ func (e *PulseToolExecutor) executeSearchResources(_ context.Context, args map[s
Type: "node",
Name: node.Name(),
Status: status,
AgentConnected: connectedAgentHostnames[node.Name()],
CommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, connectedAgentHostnames[node.Name()]),
})
}
}