From f48c806718387c63591dc9a119b3bb992f9e0e27 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:45:40 +0100 Subject: [PATCH 1/9] Preserve observed Docker storage evidence Missing block I/O and container image sizes could become false evidence for diagnosis. Preserve per-direction counter presence and measured zero through collection, resource conversion and browser rendering. Separate new observed history from ambiguous retained disk series without deleting old rows or changing public metric names. Keep partial host rates distinct and persist a newly enabled Disk I/O column across the first preference reload. --- .../PATROL_ASSISTANT_CUSTOMER_JOURNEY.md | 86 +++++++++ docs/release-control/v6/internal/status.json | 4 +- .../v6/internal/subsystems/agent-lifecycle.md | 5 + .../subsystems/frontend-primitives.md | 8 + .../v6/internal/subsystems/monitoring.md | 14 ++ .../subsystems/performance-and-scalability.md | 14 ++ .../v6/internal/subsystems/registry.json | 9 +- .../internal/subsystems/storage-recovery.md | 8 + .../internal/subsystems/unified-resources.md | 10 ++ frontend-modern/browser-verification.json | 43 +++-- .../UnifiedResourceHostTableCard.tsx | 20 ++- .../__tests__/infrastructureSelectors.test.ts | 13 ++ .../Infrastructure/infrastructureSelectors.ts | 9 +- .../docker/DockerHostDrawerOverview.tsx | 10 +- .../standalone/AgentsMachinesTable.tsx | 20 ++- .../__tests__/agentMachineTableModel.test.ts | 10 ++ .../standalone/agentMachineTableModel.ts | 4 +- .../__tests__/useColumnVisibility.test.ts | 23 +++ .../__tests__/useUnifiedResources.test.ts | 19 ++ .../src/hooks/useColumnVisibility.ts | 30 ++-- .../src/hooks/useUnifiedResources.ts | 4 +- frontend-modern/src/types/resource.ts | 4 +- .../src/utils/__tests__/format.test.ts | 11 ++ frontend-modern/src/utils/format.ts | 8 + internal/api/router_state_test.go | 2 +- internal/dockeragent/blockio_presence_test.go | 48 +++++ internal/dockeragent/collect.go | 17 +- internal/models/converters.go | 7 +- internal/models/converters_test.go | 8 +- internal/models/models_frontend.go | 4 +- .../monitoring/canonical_guardrails_test.go | 8 +- .../monitoring/docker_metric_presence_test.go | 164 ++++++++++++++++++ .../monitoring/issue1613_contract_test.go | 4 +- internal/monitoring/monitor.go | 21 +-- internal/monitoring/monitor_agents.go | 20 +-- .../monitoring/monitor_unified_state_test.go | 2 +- internal/unifiedresources/metrics.go | 17 +- internal/unifiedresources/metrics_test.go | 26 ++- pkg/agents/docker/blockio_presence_test.go | 32 ++++ pkg/agents/docker/report.go | 23 ++- pkg/metrics/docker_observation_contract.go | 35 ++++ pkg/metrics/store.go | 15 +- .../store_docker_observation_contract_test.go | 148 ++++++++++++++++ .../canonical_completion_guard_test.py | 4 + .../release_control/subsystem_lookup_test.py | 2 + 45 files changed, 864 insertions(+), 129 deletions(-) create mode 100644 internal/dockeragent/blockio_presence_test.go create mode 100644 internal/monitoring/docker_metric_presence_test.go create mode 100644 pkg/agents/docker/blockio_presence_test.go create mode 100644 pkg/metrics/docker_observation_contract.go create mode 100644 pkg/metrics/store_docker_observation_contract_test.go diff --git a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md index fdcf448c3..24ec1eb30 100644 --- a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md +++ b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md @@ -1408,3 +1408,89 @@ correction above is a separate scoped change and requires its own landing checks The redesign remains open for reliable interpretation, the config-read contract, storage/backup, approved/rejected action outcomes and supported autonomous Patrol qualification. Wider customer readiness still requires independent Pro environments. + +### Docker measurement correction plan, 2026-09-06 + +The next shared-source correction distinguishes absent block-I/O observations +from measured idle zero and removes the Docker layer-size ratio from filesystem +capacity. Counter presence uses the existing rate tracker contract. Missing +reports must not reset the baseline or fabricate samples. Container layer sizes +remain descriptive metadata. + +Persisted Docker-family disk series previously mixed invalid capacity ratios and +unobserved I/O zeros with real measurements. New disk observations use separate +physical series keys while public metric names remain unchanged. Retained reads +exclude ambiguous legacy disk series without deleting or relabelling them. The +shared app-container storage family also serves non-Docker providers, so new +valid capacity observations must remain supported. Non-Docker series retain +existing behavior. Explicit zero must survive every retained-read API and rollup. + +Browser verification is required after the final backend build. Interaction +matrix: `/docker` at 1440x1000, 900x1000 and 390x1000, container selection, +resource drawer open/close, current metrics, history expansion, measured idle, +unavailable readings, and reload. Inspect actual pixels, scrolling, focus and +Escape dismissal. `/patrol` evidence rendering must preserve absent versus zero +in current-resource and retained-history tool results. Controlled responses may +qualify rendering but cannot qualify diagnosis. Live read-only API observations +must bind to the rebuilt backend. No autonomous subscription retry or paid-model +request is authorized by this correction. + +Collection also carries optional presence for each I/O direction. Explicit zero +entries survive the report JSON. For older reports without presence, only +positive counters establish an observation, so ambiguous zeros remain unavailable +until the agent is updated or a positive baseline exists. This does not require +re-enrollment. Docker-host first-disk history and network-counter presence are +adjacent limits outside this container block-I/O correction. + +The final browser matrix also covers the shared host I/O table, Docker host +Overview and Machines table/tooltip at the same three widths. Partial read/write +observations must show a missing marker for the absent direction, retain measured +zero, and remain excluded from sums used for sorting and comparison. Exercise +column selection, hover/focus, tooltip dismissal and scrolling where present. + +### Docker correction qualification and scope + +The implementation carries per-direction presence from collection and report +JSON into the existing rate tracker, canonical resource metrics, persisted +history and resource-to-browser conversion. REST resource adaptation also +preserves optional rates. Shared rate formatting keeps missing values distinct +from zero in Machines and Docker host details. Incomplete rates do not become +complete throughput totals for sorting or comparison. A browser-discovered +first-user column migration bug is corrected in the shared preference hook, so +showing Disk I/O survives the first reload. + +Legacy workload conversion in `frontend-modern/src/hooks/useWorkloads.ts` still +uses numeric direction fields with grouped availability. Its direction-level +modernization remains a separate consumer follow-up. Docker-host first-disk +history and network presence are also outside this container measurement slice. +The correction must not be represented as complete coverage of all metrics or +all monitoring surfaces. No new model competence or autonomous action result is +claimed. + +Affected Go package checks and targeted race checks ran on pulse-dev with +Go1.26.8. The changed websocket assertion now expects observed read zero with +absent write omitted. Targeted frontend suites and type checking cover optional +rates, REST conversion, sorting, formatting and column persistence. Ten paired +read-benchmark rounds used the unchanged parent store via Go overlay. The +canonical >10%, p<0.05 regression gate passed, with +0.82% timing geomean in this +scoped comparison. This is not a fleet-load or full-product performance claim. + +The Pro backend was cross-built on pulse-dev from the changed source and +installed into the existing local dev runtime. Binary SHA256: +`59f05f954ff8080bd3e8f3054b2b059281c49172ee771b2e454c255241158a4a`. +No production agent was replaced. Older agents remain compatible and treat +ambiguous zero counters conservatively. + +Private receipts: `/Volumes/Development/pulse/tmp/patrol-docker-observed-metrics/`. +Worker logs: `/opt/pulse-release-worker/patrol-docker-observed-proof/`. +PR1934's preceding identity correction merged at +`6b0abc3bee9ffa81f6ab298b5b67ee11369688a0` with all checks passing. The current +measurement slice passed final-source Playwright inspection on `/docker`, +`/standalone/machines` and `/patrol` at 1440x1000, 900x1000 and 390x1000. +Live history, controlled absence/idle/loading/error, partial host rates, column +persistence, nested picker dismissal, tooltip focus, and expanded Assistant +evidence were exercised. The source-bound receipt is +`frontend-modern/browser-verification.json`. The unused shared host table card +has type and selector coverage, not an active-route browser claim. Controlled +responses qualify rendering only. Landing checks remain separate from the +unperformed diagnosis, approved/rejected action and recovery qualifications. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index bf404a895..f8cbf9237 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -10201,7 +10201,7 @@ }, { "id": "patrol-assistant-customer-outcome-qualification", - "summary": "The explicit Patrol/Assistant redesign goal, contract, execution plan and source-bound evidence remain in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. Model judgment owns diagnosis. Observations, hypotheses, proposals, executions and independently verified outcomes remain distinct. Assistant continues the same issue and governed action records. The recorded 2026-09-05 baseline has 127 paid installations, 71 with Patrol enabled and 23 with Assistant calls. Fourteen verified resolutions came from one installation. Schema 17 outcome/provider/cost fields had no adoption. Usage does not prove useful linked tasks or representative false-alarm, missed-problem or success rates. Shared risk, provenance, history, missing-access and diagnostic-continuity corrections have regression and named browser proof. Proposal promotion, duplicate causal inference, contextless evaluations and count-based diagnostic completion policy were removed. Independent Docker fault/oracle contracts qualify reproducible injection, negative controls and cleanup, not model competence or governed action outcomes. Integrated CI exposed retained-query performance, disk-probe ordering and route-label timing regressions. Their scoped corrections and qualification records landed through PR1928 and PR1929. PR1929 merged at cf98358c0eb46987a82def5776fa41db5f54210a with backend, frontend, benchmark, governance, CodeQL and all eight Core E2E shards passing. The current ordinary retained-history diagnosis still contradicts explicit temporal semantics and remains unqualified. Two additional read-only Assistant requests used claude-subscription:claude-opus-5 against run-owned containers on the monitored Tower host. The healthy request took 82.835s and seven tools, correctly recommending no action, but overstated absence of storage impact. The dependency request took 204.384s and sixteen tools with three failed reads. It identified the stopped dependency, preserved the missing command access and causal uncertainty, but overstated storage exclusion and recovery implications. Config reads incorrectly reported app-container not found after successful canonical gets. These single cases remain partial diagnosis evidence, not a qualification pass. The fixture deadline performed two-pass cleanup with unchanged original inventory. Post-answer fault readback and explicit recovery were not completed, so no action outcome is claimed. Captured responses exposed concurrent tool-ID merging, sibling approval removal and renderer mutation of shared evidence. The current scoped correction keeps supplied invocation IDs authoritative and stable message rows without deep transcript reconciliation. All 167 affected frontend tests pass. Final-source browser replay at /patrol, 1440/900/390x1000, preserves all seven and sixteen exact tool inputs/outputs. Controlled stream states verify concurrent progress, cancellation, failed completion and sibling approval retention without provider or infrastructure actions. Exact captures, failed reproductions, hashes and remaining limits are in the plan. This correction still requires its own scoped landing checks. Claude Max explicitly refused autonomous Patrol readiness. Cached refusal and API409 enforcement remain intact, with no bypass or repeated retry. Ordinary Assistant is not autonomous qualification. Approval for an alternate separately billed provider remains pending, and no paid request occurred. Reliable interpretation, the config-read contract, broader 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. Exact regression, browser and delivery state is recorded in the plan. 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, config-read semantics, 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", @@ -10215,6 +10215,7 @@ "ai-runtime", "api-contracts", "frontend-primitives", + "monitoring", "patrol-intelligence", "performance-and-scalability", "unified-resources" @@ -10386,6 +10387,7 @@ "ai-runtime", "api-contracts", "frontend-primitives", + "monitoring", "patrol-intelligence", "performance-and-scalability", "unified-resources" diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 301a0412f..29d4b8f97 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -15,6 +15,11 @@ ## Purpose +Docker block-I/O report presence fields are optional measurement metadata. +They preserve zero and omitted directions independently without changing report +admission, enrollment, identity, command permission or agent lifecycle state. +Older agents remain accepted, with ambiguous omitted zero counters unavailable. + The internal Patrol request bridge carries explicit execution limits and capability allowlists without a diagnostic report-count budget. Finding writes retain their server-owned scope and cannot enter or satisfy the infrastructure diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 0b217c234..cf6628297 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,14 @@ ## Purpose +Disk I/O presentation preserves each observed direction independently. Shared +formatting renders a missing rate as a dash and measured idle as numeric zero. +Partial observations cannot form a complete throughput total for sorting or +comparison. Machines column preferences must preserve an explicit user choice +across the first reload, including default-hidden migrations. Final-source +browser proof covers Docker host details, Machines column selection and tooltip +focus/dismissal at desktop, intermediate and narrow widths. + The shared delivery-health card wraps action groups according to available space, retaining readable explanation width when Review, Retry, Dismiss and Refresh appear together. Its heading uses the opaque semantic foreground, diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 9e1793589..e9c89ae80 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -17,6 +17,20 @@ ## Purpose +Docker collection records read and write counter presence independently, +including explicit zero, in optional report fields. Older reports without those +fields establish only positive counters. Container reports propagate this +presence to the shared rate tracker. An omitted block-I/O payload and the first counter sample +produce no rate history. Unchanged observed counters produce measured zero, +including after an omitted report. Container writable/root layer sizes never +produce capacity usage history. The ingestion regression is +`internal/monitoring/docker_metric_presence_test.go`. This changes measurement +projection only and grants no agent lifecycle authority. +The shared resource-to-browser conversion preserves each optional I/O rate +independently. Missing directions are omitted from JSON, while measured zero +remains numeric zero. No aggregate presence flag may fabricate its sibling +direction. `TestResourceDiskIOWirePreservesAbsentDirection` pins that wire path. + Physical disk inventory has an independent collector schedule. The PVE poller carries its default five-minute or configured interval with each disk record, while keeping the last successful observation timestamp on retained records. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 6cdd0a1f5..0b215b09d 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -15,6 +15,20 @@ ## Purpose +The Docker/app-container history families `dockercontainer` and `docker` use +separate physical `.observed` series for new disk capacity and block-I/O +measurements. Older disk series lack the required presence/capacity semantics +and remain stored unchanged, but retained reads exclude them from current +evidence. All four shared read APIs expose corrected series under the existing +public metric names. Valid capacity from other providers sharing this storage +family remains writable. `NormalizedSeriesKey` describes physical storage for +coverage/backfill matching. Rollups aggregate each physical generation separately. +Projection happens once per returned series, not per observation, and adds no +query, schema migration, or per-row work for other resource families. +`pkg/metrics/store_docker_observation_contract_test.go` pins legacy coexistence, +zero retention, selected/fleet read parity, rollup separation and unaffected +resource families. + Retained reads use one shared query contract in `pkg/metrics/store.go` for `Query`, `QueryAll`, `QueryAllBatch` and `QueryMetricTypesBatch`. A non-empty preferred resolution no longer hides a newer raw tail, an older uncovered diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index dd2a7fa00..31e433330 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1611,6 +1611,7 @@ "internal/config/host_continuity_test.go", "internal/models/metrics_types_test.go", "internal/monitoring/availability_probe_agent_test.go", + "internal/monitoring/docker_metric_presence_test.go", "internal/monitoring/monitor_host_agent_removal_lifecycle_test.go", "internal/monitoring/monitor_host_agents_test.go", "scripts/installtests/agent_state_dir_lifecycle_test.go", @@ -5945,6 +5946,7 @@ "test_prefixes": [], "exact_files": [ "internal/config/host_continuity_test.go", + "internal/monitoring/docker_metric_presence_test.go", "internal/monitoring/issue1485_unraid_lifecycle_test.go", "internal/monitoring/issue1595_collection_trust_test.go", "internal/monitoring/monitor_docker_test.go", @@ -6066,6 +6068,7 @@ "internal/dockeragent/agent_collect_test.go", "internal/dockeragent/agent_cpu_test.go", "internal/dockeragent/agent_internal_test.go", + "internal/dockeragent/blockio_presence_test.go", "internal/dockeragent/swarm_coverage_test.go" ] }, @@ -6125,13 +6128,15 @@ "internal/models/issue1639_pbs_collision_test.go", "internal/models/metrics_types_test.go", "internal/models/state_host_test.go", + "internal/monitoring/docker_metric_presence_test.go", "internal/monitoring/issue1595_collection_trust_test.go", "internal/monitoring/monitor_full_coverage_test.go", "internal/monitoring/monitor_host_agent_removal_lifecycle_test.go", "internal/monitoring/monitor_host_agents_test.go", "internal/monitoring/monitor_package_updates_test.go", "internal/unifiedresources/adapter_coverage_test.go", - "internal/unifiedresources/registry_test.go" + "internal/unifiedresources/registry_test.go", + "pkg/agents/docker/blockio_presence_test.go" ] }, { @@ -7005,6 +7010,7 @@ "exact_files": [ "pkg/metrics/store_additional_test.go", "pkg/metrics/store_bench_test.go", + "pkg/metrics/store_docker_observation_contract_test.go", "pkg/metrics/store_query_plan_test.go", "pkg/metrics/store_slo_test.go" ] @@ -7132,6 +7138,7 @@ "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ + "frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.test.ts", "frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx", "frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts", "frontend-modern/src/components/Infrastructure/__tests__/useTableWindowing.test.ts", diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 562ae7917..dc956e1c8 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -21,6 +21,14 @@ ## Purpose +Container image-layer sizes do not establish filesystem capacity. Docker +resource metrics omit that invalid ratio, and retained queries exclude legacy +ambiguous disk observations while preserving new valid provider measurements. +The shared REST resource projection retains missing read/write directions +independently of measured zero. An idle I/O rate cannot establish available +capacity, backup coverage or recoverability. This correction adds no recovery +authority or verified recovery outcome. + Patrol consumes storage evidence in the original diagnostic conversation. A saved finding does not close storage-read authority before the explicit run limit. Missing backup or recovery evidence remains unknown, and neither finding diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 6739cc88e..077ac9f72 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -15,6 +15,16 @@ ## Purpose +Docker container writable/root layer bytes describe image composition, not +used/total filesystem capacity, and cannot populate `ResourceMetrics.Disk`. +Optional valid block-I/O rate pointers preserve measured zero. Missing, negative +and non-finite rates remain unavailable. Raw layer metadata remains available. +`TestMetricsFromDockerContainerDistinguishesAbsentAndIdleIO` and the container +I/O projection proof in `internal/unifiedresources/metrics_test.go` pin this +boundary. +The frontend resource contract likewise makes read and write rates independently +optional, preserving this distinction through current-history labels. + Physical disk source freshness preserves the collector-authored `expectedUpdateIntervalSeconds` alongside the actual last observation. Registry ingest, merge, cloning and typed disk views preserve it. Staleness uses the diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index a942fbaa7..dd102f878 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,18 +1,34 @@ { "version": 1, - "base_sha": "9b4fa52d84e6196108e961ccba2b317df497268b", - "verified_at": "2026-09-06T10:15:18.709765Z", + "base_sha": "6b0abc3bee9ffa81f6ab298b5b67ee11369688a0", + "verified_at": "2026-09-06T11:20:14.502Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/AI/Chat/ChatMessages.tsx", - "frontend-modern/src/components/AI/Chat/hooks/useChat.ts" + "frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx", + "frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts", + "frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx", + "frontend-modern/src/features/standalone/AgentsMachinesTable.tsx", + "frontend-modern/src/features/standalone/agentMachineTableModel.ts", + "frontend-modern/src/hooks/useColumnVisibility.ts", + "frontend-modern/src/hooks/useUnifiedResources.ts", + "frontend-modern/src/types/resource.ts", + "frontend-modern/src/utils/format.ts" ], "content_sha256": { - "frontend-modern/src/components/AI/Chat/ChatMessages.tsx": "9672f7608d1e3a531c73cba20fd4a78752316783212afd0c292ddfd11d2bf371", - "frontend-modern/src/components/AI/Chat/hooks/useChat.ts": "0b56b7a56e35d51ca96f0e126dd493b3164aa9e0ad4d8ae24bcf3af7a574b97c" + "frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx": "f183ef158f646d52b9b2ff6c3af028d7b9b993a27542690ad664ab84f93d0d73", + "frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts": "a3ff0b74581e769db70b12dba94e30324aa52ccbb7242b7513cf82b8f8e2fd5e", + "frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx": "8957154b965f4e4240e7c19888fada63dcaf518a9e0e3a0e384b184ebaf4e716", + "frontend-modern/src/features/standalone/AgentsMachinesTable.tsx": "4342ba59fe9dbd33ae85131a2da45ac7ebeb7efe61457b43ce32640109bd3214", + "frontend-modern/src/features/standalone/agentMachineTableModel.ts": "ae419b49a1d761910612bf95b82e9c6226374c648d48d252259ed70b30b955c0", + "frontend-modern/src/hooks/useColumnVisibility.ts": "0112d88ca35fc0f1f3866070aa0913c706370bd6c0fb954afb7a89a34bd25cf3", + "frontend-modern/src/hooks/useUnifiedResources.ts": "4a434a0c0d2f42c1ee9dbed9ca3353ee5978821df2d9d725db1f4904f7a77b9e", + "frontend-modern/src/types/resource.ts": "a4d45afd0a4797ffe7075e8c4b0890e59f573ea8503a31eee68e22d2843cedd8", + "frontend-modern/src/utils/format.ts": "e0db6c68c1219481a9a3562239b35027916a04b1af6ec8df6fce0534a54ef8db" }, "routes": [ - "/patrol" + "/docker", + "/patrol", + "/standalone/machines" ], "viewports": [ { @@ -29,12 +45,15 @@ } ], "states": [ - "Captured healthy and dependency Assistant responses replayed without provider calls. All 7 and 16 terminal tool records retain exact inputs, outputs and distinct rows. Success, failed NO_AGENT and unsupported config results inspected.", - "Controlled stream: concurrent same-name calls, repeated starts, progress, two separate approval cards, sibling completion, canceled call and failed completion. No real action was attempted.", - "Restored persisted healthy and dependency sessions retain original prompts and evidence. Artificial route warning reflects blocked non-GET readiness checks, not route qualification." + "Live Docker container Overview and retained History against rebuilt Pro backend 59f05f954ff8080bd3e8f3054b2b059281c49172ee771b2e454c255241158a4a. Public history excludes legacy capacity and ambiguous I/O, while new observed rates remain available. Binary hash checked before and after.", + "Controlled Docker history loading, empty/unavailable, observed read zero with absent write, and error states. Controlled host partial read/write in Docker Overview and Machines. Missing directions display a dash and observed zero displays 0 B/s.", + "Controlled Assistant history tool completion on /patrol preserves zero read samples and an empty write series. This qualifies rendering only. Provider calls and infrastructure mutations were blocked. The artificial route warning is not provider health evidence.", + "Machines Disk I/O column enabled from fresh preferences, tooltip open/closed and persisted after reload at desktop. Existing responsive policy hides the column at 900 and 390 pixels. The unused UnifiedResourceHostTableCard has type and selector coverage, not an active-route browser claim." ], "interactions": [ - "Hover, focus, Enter/Space expand and collapse, scroll expanded output to its end, Escape, reload and select the original persisted session. Tool details and wrapping inspected at desktop, intermediate and narrow widths.", - "Private evidence: /Volumes/Development/pulse/tmp/patrol-assistant-lab-readiness/identity-verified-healthy, identity-verified-fault and identity-states. Baseline reproductions retained in identity-before-fault and failed focused regressions." + "Container selection, Overview/History switching, keyboard focus, chart hover, scroll to deepest history, collapse and reopen, reload and history re-entry at 1440, 900 and 390 pixels. Inspected actual pixels for placement, wrapping and scrolling.", + "Machines View > Columns > Disk I/O, selected state, Escape from nested picker and parent, hover and keyboard focus on I/O tooltip, Escape and outside dismissal, reload preserving enabled column. Docker host Overview inspected at all three widths.", + "Assistant tool details expanded and collapsed with Enter and Space, output scrolled and inspected, and Escape dismissal at all widths. No action or model diagnosis was simulated as qualification.", + "Private final-source evidence: /Volumes/Development/pulse/tmp/patrol-docker-observed-metrics/browser, evidence-states and host-states. All three Playwright scripts passed after the final source edit. Runtime source hashes are recorded in runtime-binding.json." ] } diff --git a/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx b/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx index c6bbdd20c..e81d3612f 100644 --- a/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx +++ b/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx @@ -3,6 +3,7 @@ import type { Component } from 'solid-js'; import { formatBytes, formatSpeed, + formatObservedSpeed, formatUptime, getResourceDiskSummary, normalizeDiskArray, @@ -334,8 +335,11 @@ export const UnifiedResourceHostTableCard: Component getOutlierEmphasis(networkTotal(), table.ioScale().network), ); - const diskIOTotal = createMemo( - () => (resource.diskIO?.readRate ?? 0) + (resource.diskIO?.writeRate ?? 0), + const diskIOTotal = createMemo(() => + resource.diskIO?.readRate !== undefined && + resource.diskIO?.writeRate !== undefined + ? resource.diskIO.readRate + resource.diskIO.writeRate + : NaN, ); const diskIOEmphasis = createMemo(() => getOutlierEmphasis(diskIOTotal(), table.ioScale().diskIO), @@ -662,11 +666,11 @@ export const UnifiedResourceHostTableCard: Component - {formatSpeed(resource.diskIO!.readRate)} + {formatObservedSpeed(resource.diskIO!.readRate)} W @@ -675,11 +679,11 @@ export const UnifiedResourceHostTableCard: Component - {formatSpeed(resource.diskIO!.writeRate)} + {formatObservedSpeed(resource.diskIO!.writeRate)} diff --git a/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.test.ts index d71e08b27..23406beda 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.test.ts @@ -458,3 +458,16 @@ describe('infrastructureSelectors', () => { }); }); }); + +it('does not compare partial disk observations as complete throughput totals', () => { + const known = [ + makeResource(1, { diskIO: { readRate: 0, writeRate: 0 } }), + makeResource(2, { diskIO: { readRate: 100, writeRate: 200 } }), + ]; + const partial = [ + makeResource(3, { diskIO: { readRate: 10000 } }), + makeResource(4, { diskIO: { writeRate: 0 } }), + makeResource(5, { diskIO: {} }), + ]; + expect(computeIOScale([...known, ...partial]).diskIO).toEqual(computeIOScale(known).diskIO); +}); diff --git a/frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts b/frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts index 230feaee7..2110fe9a3 100644 --- a/frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts +++ b/frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts @@ -86,7 +86,9 @@ const getSortValue = (resource: Resource, key: string): number | string | null = case 'network': return resource.network ? resource.network.rxBytes + resource.network.txBytes : null; case 'diskio': - return resource.diskIO ? resource.diskIO.readRate + resource.diskIO.writeRate : null; + return resource.diskIO?.readRate !== undefined && resource.diskIO?.writeRate !== undefined + ? resource.diskIO.readRate + resource.diskIO.writeRate + : null; case 'source': return getInfrastructureSystemIdentitySortLabel(resource); case 'temp': @@ -349,9 +351,8 @@ export const computeIOScale = ( networkValues.push(networkTotal); } - const diskIOTotal = (resource.diskIO?.readRate ?? 0) + (resource.diskIO?.writeRate ?? 0); - if (resource.diskIO) { - diskIOValues.push(diskIOTotal); + if (resource.diskIO?.readRate !== undefined && resource.diskIO?.writeRate !== undefined) { + diskIOValues.push(resource.diskIO.readRate + resource.diskIO.writeRate); } } diff --git a/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx b/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx index 3d77cdedb..f3cce4702 100644 --- a/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx +++ b/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx @@ -20,7 +20,13 @@ import { hostOverrideIdCandidates } from '@/features/alerts/alertOverridesModel' import { areSystemSettingsLoaded, shouldHideDockerUpdateActions } from '@/stores/systemSettings'; import { useAlertsActivation } from '@/stores/alertsActivation'; import type { Resource } from '@/types/resource'; -import { formatBytes, formatRelativeTime, formatSpeed, normalizeDiskArray } from '@/utils/format'; +import { + formatBytes, + formatRelativeTime, + formatSpeed, + formatObservedSpeed, + normalizeDiskArray, +} from '@/utils/format'; import { formatTemperature, getTemperatureTextClass } from '@/utils/temperature'; interface DockerHostDrawerOverviewProps { @@ -279,7 +285,7 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) { ) { rows.push({ label: 'Disk I/O', - value: `${formatSpeed(props.host.diskIO?.readRate ?? 0)} / ${formatSpeed(props.host.diskIO?.writeRate ?? 0)}`, + value: `${formatObservedSpeed(props.host.diskIO?.readRate)} / ${formatObservedSpeed(props.host.diskIO?.writeRate)}`, }); } return rows; diff --git a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx index 698aeb37c..9aad830f5 100644 --- a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx +++ b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx @@ -61,7 +61,7 @@ import type { Disk } from '@/types/api'; import type { Resource, ResourceAvailabilityMeta } from '@/types/resource'; import type { MetricDisplayThresholds } from '@/utils/metricThresholds'; import { getActionableAgentIdFromResource } from '@/utils/agentResources'; -import { formatBytes, formatSpeed, normalizeDiskArray } from '@/utils/format'; +import { formatBytes, formatSpeed, formatObservedSpeed, normalizeDiskArray } from '@/utils/format'; import { STORAGE_KEYS } from '@/utils/localStorage'; import { useAlertsActivation } from '@/stores/alertsActivation'; import { notificationStore } from '@/stores/notifications'; @@ -84,7 +84,6 @@ import { getAgentMachineGPUTitle, getAgentMachineGPUUtilizationPercent, getAgentMachineDiskIODetails, - getAgentMachineDiskIOTotal, getAgentMachineIpValues, matchesAgentMachineSearch, getAgentMachineNetworkInterfaceDetails, @@ -502,9 +501,9 @@ const AgentMachineDiskIOCell: Component<{ trigger={ <> R - {formatSpeed(props.diskIO?.readRate ?? 0)} + {formatObservedSpeed(props.diskIO?.readRate)} W - {formatSpeed(props.diskIO?.writeRate ?? 0)} + {formatObservedSpeed(props.diskIO?.writeRate)} } > @@ -513,11 +512,11 @@ const AgentMachineDiskIOCell: Component<{
Read - {formatSpeed(props.diskIO?.readRate ?? 0)} + {formatObservedSpeed(props.diskIO?.readRate)} Write - {formatSpeed(props.diskIO?.writeRate ?? 0)} + {formatObservedSpeed(props.diskIO?.writeRate)}
@@ -1038,7 +1037,7 @@ const networkTitleFor = (machine: Resource): string => { const diskIOTitleFor = (machine: Resource): string => { if (!machine.diskIO) return ''; - return `Read ${formatSpeed(machine.diskIO.readRate)}\nWrite ${formatSpeed(machine.diskIO.writeRate)}`; + return `Read ${formatObservedSpeed(machine.diskIO.readRate)}\nWrite ${formatObservedSpeed(machine.diskIO.writeRate)}`; }; const agentIdentityIdFor = (machine: Resource): string => @@ -1527,7 +1526,6 @@ export const AgentsMachinesTable: Component<{ aggregateDisk() !== undefined || (disks()?.length ?? 0) > 0; const networkTotal = () => getAgentMachineNetworkTotal(machine); const networkInterfaces = () => getAgentMachineNetworkInterfaceDetails(machine); - const diskIOTotal = () => getAgentMachineDiskIOTotal(machine); const diskIODetails = () => getAgentMachineDiskIODetails(machine); const primaryIp = () => getPreferredResourceIP(machine) ?? getAgentMachinePrimaryIp(machine); @@ -1738,7 +1736,11 @@ export const AgentsMachinesTable: Component<{ class={`${getPlatformTableCellClassForKind('numeric-value')} ${machineColumnWidthClass('diskio')} text-base-content`} > { ); }); }); + +it('requires both disk directions for a throughput total', () => { + expect(getAgentMachineDiskIOTotal(resource({ diskIO: { readRate: 0, writeRate: 0 } }))).toBe(0); + expect(getAgentMachineDiskIOTotal(resource({ diskIO: { readRate: 100, writeRate: 200 } }))).toBe( + 300, + ); + expect(getAgentMachineDiskIOTotal(resource({ diskIO: { readRate: 0 } }))).toBeUndefined(); + expect(getAgentMachineDiskIOTotal(resource({ diskIO: { writeRate: 100 } }))).toBeUndefined(); +}); diff --git a/frontend-modern/src/features/standalone/agentMachineTableModel.ts b/frontend-modern/src/features/standalone/agentMachineTableModel.ts index f8307964e..5fe81f91f 100644 --- a/frontend-modern/src/features/standalone/agentMachineTableModel.ts +++ b/frontend-modern/src/features/standalone/agentMachineTableModel.ts @@ -493,8 +493,8 @@ export const getAgentMachineNetworkInterfaceDetails = ( export const getAgentMachineDiskIOTotal = (machine: Resource): number | undefined => { const read = getPlatformTableFiniteMetric(machine.diskIO?.readRate); const write = getPlatformTableFiniteMetric(machine.diskIO?.writeRate); - if (read === undefined && write === undefined) return undefined; - return (read ?? 0) + (write ?? 0); + if (read === undefined || write === undefined) return undefined; + return read + write; }; export const getAgentMachineDiskIODetails = (machine: Resource): AgentMachineDiskIODetail[] => { diff --git a/frontend-modern/src/hooks/__tests__/useColumnVisibility.test.ts b/frontend-modern/src/hooks/__tests__/useColumnVisibility.test.ts index 18ae6ed4f..e6d4460e8 100644 --- a/frontend-modern/src/hooks/__tests__/useColumnVisibility.test.ts +++ b/frontend-modern/src/hooks/__tests__/useColumnVisibility.test.ts @@ -120,6 +120,29 @@ describe('useColumnVisibility', () => { }); }); + it('does not reapply a default-hidden migration after a fresh user shows the column', async () => { + const columns: ColumnDef[] = [ + { id: 'name', label: 'Name' }, + { id: 'diskio', label: 'Disk I/O', toggleable: true, defaultHidden: true }, + ]; + let dispose = () => {}; + let visibility: ReturnType; + createRoot((d) => { + dispose = d; + visibility = useColumnVisibility(storageKey, columns, [], undefined, {}, ['diskio']); + }); + await Promise.resolve(); + visibility!.show('diskio'); + await Promise.resolve(); + expect(window.localStorage.getItem(storageKey)).toBe('[]'); + dispose(); + createRoot((d) => { + const reloaded = useColumnVisibility(storageKey, columns, [], undefined, {}, ['diskio']); + expect(reloaded.isHiddenByUser('diskio')).toBe(false); + d(); + }); + }); + it('resets back to the canonical default-hidden set', () => { createRoot((dispose) => { const columns: ColumnDef[] = [ diff --git a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts index 74aba914e..45ea92111 100644 --- a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts +++ b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts @@ -1451,6 +1451,25 @@ describe('useUnifiedResources', () => { dispose(); }); + it('preserves an observed zero without inventing its absent I/O direction', async () => { + setWsConnected(false); + setWsInitialDataReceived(false); + setWsState('resources', []); + apiFetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [{ ...v2Resource, metrics: { diskRead: { value: 0 } } }] }), + }); + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useUnifiedResources(); + }); + await result!.refetch(); + expect(result!.resources()[0]?.diskIO).toEqual({ readRate: 0, writeRate: undefined }); + dispose(); + }); + it('preserves richer REST resource details across thinner websocket updates', async () => { setWsConnected(false); setWsInitialDataReceived(false); diff --git a/frontend-modern/src/hooks/useColumnVisibility.ts b/frontend-modern/src/hooks/useColumnVisibility.ts index 8a4d5bc43..23953810b 100644 --- a/frontend-modern/src/hooks/useColumnVisibility.ts +++ b/frontend-modern/src/hooks/useColumnVisibility.ts @@ -124,21 +124,21 @@ export function useColumnVisibility( const appliedDefaultHiddenMigrations = hasUserPreference ? readAppliedDefaultHiddenMigrations(storageKey, persistedIdAliases) : []; - const pendingDefaultHiddenMigrations = hasUserPreference - ? Array.from( - new Set( - defaultHiddenMigrationIds - .map((id) => id.trim()) - .filter( - (id) => - id && - effectiveDefaultHidden.includes(id) && - toggleableIds.includes(id) && - !appliedDefaultHiddenMigrations.includes(id), - ), + // Fresh preferences already contain these defaults. Mark their migration as + // applied too, so the first reload cannot undo a user's subsequent choice. + const pendingDefaultHiddenMigrations = Array.from( + new Set( + defaultHiddenMigrationIds + .map((id) => id.trim()) + .filter( + (id) => + id && + effectiveDefaultHidden.includes(id) && + toggleableIds.includes(id) && + !appliedDefaultHiddenMigrations.includes(id), ), - ) - : []; + ), + ); let defaultHiddenMigrationsPersisted = false; // Persist hidden columns to localStorage @@ -169,7 +169,7 @@ export function useColumnVisibility( createEffect(() => { const hasUnpersistedDefaultHiddenMigrations = pendingDefaultHiddenMigrations.length > 0 && !defaultHiddenMigrationsPersisted; - if (!hasUserPreference || (!persistedIdsMigrated && !hasUnpersistedDefaultHiddenMigrations)) { + if (!persistedIdsMigrated && !hasUnpersistedDefaultHiddenMigrations) { return; } persistedIdsMigrated = false; diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index aace88c99..a9b46a114 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -915,8 +915,8 @@ const toResource = (v2: APIResource): Resource => { diskIO: v2.metrics?.diskRead || v2.metrics?.diskWrite ? { - readRate: v2.metrics?.diskRead?.value ?? 0, - writeRate: v2.metrics?.diskWrite?.value ?? 0, + readRate: v2.metrics?.diskRead?.value, + writeRate: v2.metrics?.diskWrite?.value, } : undefined, uptime: diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index d0d1263ce..8fe58ef6e 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -133,8 +133,8 @@ export interface ResourceNetwork { // Disk I/O metrics (rates in bytes/sec from backend) export interface ResourceDiskIO { - readRate: number; // Read rate (bytes/sec) - writeRate: number; // Write rate (bytes/sec) + readRate?: number; // Observed read rate (bytes/sec), including measured zero. + writeRate?: number; // Absent directions remain unavailable. } // Alert associated with a resource diff --git a/frontend-modern/src/utils/__tests__/format.test.ts b/frontend-modern/src/utils/__tests__/format.test.ts index b501850ba..bf337a3b8 100644 --- a/frontend-modern/src/utils/__tests__/format.test.ts +++ b/frontend-modern/src/utils/__tests__/format.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { formatBytes, formatSpeed, + formatObservedSpeed, formatPercent, formatNumber, formatUptime, @@ -362,3 +363,13 @@ describe('getBackupInfo', () => { }); }); }); + +describe('formatObservedSpeed', () => { + it('preserves zero and leaves missing or invalid observations unavailable', () => { + expect(formatObservedSpeed(0)).toBe('0 B/s'); + expect(formatObservedSpeed(1024)).toBe('1.00 KB/s'); + for (const value of [undefined, null, -1, NaN, Infinity]) { + expect(formatObservedSpeed(value)).toBe('-'); + } + }); +}); diff --git a/frontend-modern/src/utils/format.ts b/frontend-modern/src/utils/format.ts index 223157944..01ed35b2b 100644 --- a/frontend-modern/src/utils/format.ts +++ b/frontend-modern/src/utils/format.ts @@ -67,6 +67,14 @@ export function formatSpeed(bytesPerSecond: number, decimals: number | 'auto' = return `${formatBytes(bytesPerSecond, decimals)}/s`; } +export function formatObservedSpeed(bytesPerSecond: number | null | undefined): string { + return typeof bytesPerSecond === 'number' && + Number.isFinite(bytesPerSecond) && + bytesPerSecond >= 0 + ? formatSpeed(bytesPerSecond) + : '-'; +} + export function formatPercent(value: number): string { if (!Number.isFinite(value)) return '0%'; const abs = Math.abs(value); diff --git a/internal/api/router_state_test.go b/internal/api/router_state_test.go index 2ff24521f..db89c0727 100644 --- a/internal/api/router_state_test.go +++ b/internal/api/router_state_test.go @@ -175,7 +175,7 @@ func TestRouterHandleStatePreservesNumericIdleRatesAndOmitsUnknownRates(t *testi byName[resource.Name] = resource } idle := byName["idle-vm"] - if idle.DiskIO == nil || idle.DiskIO.ReadRate != 0 || idle.DiskIO.WriteRate != 0 { + if idle.DiskIO == nil || idle.DiskIO.ReadRate == nil || idle.DiskIO.WriteRate == nil || *idle.DiskIO.ReadRate != 0 || *idle.DiskIO.WriteRate != 0 { t.Fatalf("valid idle rates were not emitted as numeric zero: %+v", idle.DiskIO) } if unknown := byName["unknown-vm"]; unknown.DiskIO != nil { diff --git a/internal/dockeragent/blockio_presence_test.go b/internal/dockeragent/blockio_presence_test.go new file mode 100644 index 000000000..da6de610e --- /dev/null +++ b/internal/dockeragent/blockio_presence_test.go @@ -0,0 +1,48 @@ +package dockeragent + +import ( + "encoding/json" + "testing" + + containertypes "github.com/moby/moby/api/types/container" + agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" +) + +func TestSummarizeBlockIOPreservesDirectionPresenceAndZero(t *testing.T) { + for _, tc := range []struct { + name string + entries []containertypes.BlkioStatEntry + read, write bool + }{ + {"absent", nil, false, false}, + {"unrelated", []containertypes.BlkioStatEntry{{Op: "Total", Value: 100}}, false, false}, + {"observed idle", []containertypes.BlkioStatEntry{{Op: "Read", Value: 0}, {Op: "Write", Value: 0}}, true, true}, + {"read only idle", []containertypes.BlkioStatEntry{{Op: "Read", Value: 0}}, true, false}, + {"write only", []containertypes.BlkioStatEntry{{Op: "Write", Value: 123}}, false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + got := summarizeBlockIO(containertypes.StatsResponse{BlkioStats: containertypes.BlkioStats{IoServiceBytesRecursive: tc.entries}}) + if !tc.read && !tc.write { + if got != nil { + t.Fatalf("absent counters became observations: %+v", got) + } + return + } + if got == nil { + t.Fatal("explicit counters were dropped") + } + encoded, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + var decoded agentsdocker.ContainerBlockIO + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + read, write := decoded.CounterPresence() + if read != tc.read || write != tc.write { + t.Fatalf("presence lost through report JSON %s: %v/%v", encoded, read, write) + } + }) + } +} diff --git a/internal/dockeragent/collect.go b/internal/dockeragent/collect.go index ef318a2c9..ebb8d0dfa 100644 --- a/internal/dockeragent/collect.go +++ b/internal/dockeragent/collect.go @@ -1318,35 +1318,34 @@ func randomDuration(max time.Duration) time.Duration { } func summarizeBlockIO(stats containertypes.StatsResponse) *agentsdocker.ContainerBlockIO { - // BlkioStats structure varies by cgroup version - // Cgroup v1: IoServiceBytesRecursive []BlkioStatEntry - // Cgroup v2: IoServiceBytesRecursive is empty? No, Docker maps it? - // Docker API guarantees IoServiceBytesRecursive is populated? - // It seems to try to handle both. - if len(stats.BlkioStats.IoServiceBytesRecursive) == 0 { return nil } var readBytes, writeBytes uint64 + var readPresent, writePresent bool for _, entry := range stats.BlkioStats.IoServiceBytesRecursive { op := strings.ToLower(entry.Op) switch op { case "read": + readPresent = true readBytes += entry.Value case "write": + writePresent = true writeBytes += entry.Value } } - if readBytes == 0 && writeBytes == 0 { + if !readPresent && !writePresent { return nil } return &agentsdocker.ContainerBlockIO{ - ReadBytes: readBytes, - WriteBytes: writeBytes, + ReadBytes: readBytes, + WriteBytes: writeBytes, + ReadBytesPresent: &readPresent, + WriteBytesPresent: &writePresent, } } diff --git a/internal/models/converters.go b/internal/models/converters.go index da5c5b882..607863401 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -1163,9 +1163,8 @@ type ResourceConvertInput struct { NetworkRX int64 NetworkTX int64 HasNetwork bool - DiskReadRate int64 - DiskWriteRate int64 - HasDiskIO bool + DiskReadRate *int64 + DiskWriteRate *int64 Temperature *float64 Uptime *int64 Tags []string @@ -1343,7 +1342,7 @@ func ConvertResourceToFrontend(input ResourceConvertInput) ResourceFrontend { } } - if input.HasDiskIO { + if input.DiskReadRate != nil || input.DiskWriteRate != nil { rf.DiskIO = &ResourceDiskIOFrontend{ ReadRate: input.DiskReadRate, WriteRate: input.DiskWriteRate, diff --git a/internal/models/converters_test.go b/internal/models/converters_test.go index 2fab91577..2c2f24aac 100644 --- a/internal/models/converters_test.go +++ b/internal/models/converters_test.go @@ -289,6 +289,7 @@ func TestVMToFrontend_NegativeNetworkValues(t *testing.T) { } func TestConvertResourceToFrontendIncludesDiskIO(t *testing.T) { + read, write := int64(4096), int64(8192) frontend := ConvertResourceToFrontend(ResourceConvertInput{ ID: "agent-1", Type: "agent", @@ -299,15 +300,14 @@ func TestConvertResourceToFrontendIncludesDiskIO(t *testing.T) { SourceType: "agent", Status: "online", LastSeenUnix: time.Now().UnixMilli(), - HasDiskIO: true, - DiskReadRate: 4096, - DiskWriteRate: 8192, + DiskReadRate: &read, + DiskWriteRate: &write, }) if frontend.DiskIO == nil { t.Fatal("expected disk I/O rates to be present") } - if frontend.DiskIO.ReadRate != 4096 || frontend.DiskIO.WriteRate != 8192 { + if frontend.DiskIO.ReadRate == nil || frontend.DiskIO.WriteRate == nil || *frontend.DiskIO.ReadRate != 4096 || *frontend.DiskIO.WriteRate != 8192 { t.Fatalf("unexpected disk I/O rates: %+v", frontend.DiskIO) } } diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index 5eecd6ab7..36ed0a480 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -1152,8 +1152,8 @@ type ResourceNetworkFrontend struct { // ResourceDiskIOFrontend represents aggregate disk I/O rates for the frontend. type ResourceDiskIOFrontend struct { - ReadRate int64 `json:"readRate"` - WriteRate int64 `json:"writeRate"` + ReadRate *int64 `json:"readRate,omitempty"` + WriteRate *int64 `json:"writeRate,omitempty"` } // ResourceAlertFrontend represents an alert on a resource. diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index ac7f80490..bd58ae94d 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -211,15 +211,15 @@ func TestProxmoxActionObserverUsesDirectControlPlaneClient(t *testing.T) { } func TestBroadcastResourceDiskIOUsesUnifiedResourceMetrics(t *testing.T) { - hasDiskIO, readRate, writeRate := monitorDiskIOMetricInput(&unifiedresources.ResourceMetrics{ + readRate, writeRate := monitorDiskIOMetricInput(&unifiedresources.ResourceMetrics{ DiskRead: &unifiedresources.MetricValue{Value: 4096.4, Unit: "bytes/s", Source: unifiedresources.SourceAgent}, DiskWrite: &unifiedresources.MetricValue{Value: 8191.6, Unit: "bytes/s", Source: unifiedresources.SourceAgent}, }) - if !hasDiskIO { + if readRate == nil || writeRate == nil { t.Fatal("expected disk I/O metrics to be projected") } - if readRate != 4096 || writeRate != 8192 { - t.Fatalf("unexpected projected disk I/O rates: read=%d write=%d", readRate, writeRate) + if *readRate != 4096 || *writeRate != 8192 { + t.Fatalf("unexpected projected disk I/O rates: read=%d write=%d", *readRate, *writeRate) } } diff --git a/internal/monitoring/docker_metric_presence_test.go b/internal/monitoring/docker_metric_presence_test.go new file mode 100644 index 000000000..717d745e3 --- /dev/null +++ b/internal/monitoring/docker_metric_presence_test.go @@ -0,0 +1,164 @@ +package monitoring + +import ( + "encoding/json" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/mock" + agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" + "github.com/rcourtman/pulse-go-rewrite/pkg/metrics" +) + +func TestApplyDockerReportPreservesDiskObservationPresence(t *testing.T) { + previous := mock.IsMockEnabled() + mustSetMockEnabled(t, false) + t.Cleanup(func() { mustSetMockEnabled(t, previous) }) + m := newTestMonitor(t) + store, err := metrics.NewStore(metrics.DefaultConfig(t.TempDir())) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + m.metricsStore = store + start := time.Now().Add(-time.Minute) + report := agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "presence-agent", Version: "6.4.2", IntervalSeconds: 30}, + Host: agentsdocker.HostInfo{Hostname: "presence-host"}, + Containers: []agentsdocker.Container{{ID: "presence-container", Name: "api", WritableLayerBytes: 200, RootFilesystemBytes: 1000}}, + } + observed := &agentsdocker.ContainerBlockIO{ReadBytes: 5000, WriteBytes: 7000} + steps := []struct { + name string + io *agentsdocker.ContainerBlockIO + wantSamples int + }{ + {"absent", nil, 0}, + {"first observation", observed, 0}, + {"measured idle", observed, 1}, + {"missing after observation", nil, 1}, + {"idle after gap", observed, 2}, + } + for i, step := range steps { + t.Run(step.name, func(t *testing.T) { + report.Timestamp = start.Add(time.Duration(i) * time.Second) + report.Containers[0].BlockIO = step.io + host, err := m.ApplyDockerReport(report, nil) + if err != nil { + t.Fatal(err) + } + if len(host.Containers) != 1 { + t.Fatalf("containers: %+v", host.Containers) + } + ct := host.Containers[0] + if step.io == nil && ct.BlockIO != nil { + t.Fatalf("absent BlockIO retained: %+v", ct.BlockIO) + } + if i == 1 && (ct.BlockIO.ReadRateBytesPerSecond != nil || ct.BlockIO.WriteRateBytesPerSecond != nil) { + t.Fatalf("warmup became a rate: %+v", ct.BlockIO) + } + if i == 2 || i == 4 { + if ct.BlockIO.ReadRateBytesPerSecond == nil || *ct.BlockIO.ReadRateBytesPerSecond != 0 || ct.BlockIO.WriteRateBytesPerSecond == nil || *ct.BlockIO.WriteRateBytesPerSecond != 0 { + t.Fatalf("unchanged counters must remain measured idle across gaps: %+v", ct.BlockIO) + } + } + store.Flush() + for _, metric := range []string{"diskread", "diskwrite"} { + points := m.metricsHistory.GetGuestMetrics("docker:"+ct.ID, metric, time.Hour) + if len(points) != step.wantSamples { + t.Fatalf("%s history = %d, want %d", metric, len(points), step.wantSamples) + } + persisted, err := store.Query("dockerContainer", ct.ID, metric, start, time.Now().Add(time.Minute), 0) + if err != nil { + t.Fatal(err) + } + if step.wantSamples == 0 && len(persisted) != 0 { + t.Fatalf("fabricated persisted %s: %+v", metric, persisted) + } + if step.wantSamples > 0 && (len(persisted) == 0 || persisted[len(persisted)-1].Value != 0) { + t.Fatalf("lost persisted idle %s: %+v", metric, persisted) + } + } + if points := m.metricsHistory.GetGuestMetrics("docker:"+ct.ID, "disk", time.Hour); len(points) != 0 { + t.Fatalf("layer sizes became capacity history: %+v", points) + } + points, err := store.Query("dockerContainer", ct.ID, "disk", start, time.Now().Add(time.Minute), 0) + if err != nil || len(points) != 0 { + t.Fatalf("layer sizes became persisted capacity: %+v, %v", points, err) + } + }) + } +} + +func TestApplyDockerReportPreservesIndependentZeroCounters(t *testing.T) { + previous := mock.IsMockEnabled() + mustSetMockEnabled(t, false) + t.Cleanup(func() { mustSetMockEnabled(t, previous) }) + m := newTestMonitor(t) + present, absent := true, false + report := agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "zero-agent", Version: "6.4.2", IntervalSeconds: 30}, + Host: agentsdocker.HostInfo{Hostname: "zero-host"}, + Containers: []agentsdocker.Container{{ID: "zero-container", Name: "idle", BlockIO: &agentsdocker.ContainerBlockIO{ReadBytesPresent: &present, WriteBytesPresent: &absent}}}, + } + for i := 0; i < 2; i++ { + report.Timestamp = time.Now().Add(time.Duration(i) * time.Second) + host, err := m.ApplyDockerReport(report, nil) + if err != nil { + t.Fatal(err) + } + io := host.Containers[0].BlockIO + if io == nil { + t.Fatal("explicit zero counter lost") + } + if io.WriteRateBytesPerSecond != nil { + t.Fatalf("missing write direction became measured rate: %+v", io) + } + if i == 0 && io.ReadRateBytesPerSecond != nil { + t.Fatal("first zero counter became a measured rate") + } + if i == 1 && (io.ReadRateBytesPerSecond == nil || *io.ReadRateBytesPerSecond != 0) { + t.Fatalf("measured zero lost: %+v", io) + } + } + if points := m.metricsHistory.GetGuestMetrics("docker:zero-container", "diskwrite", time.Hour); len(points) != 0 { + t.Fatalf("missing writes recorded: %+v", points) + } + if points := m.metricsHistory.GetGuestMetrics("docker:zero-container", "diskread", time.Hour); len(points) != 1 { + t.Fatalf("idle reading lost: %+v", points) + } +} + +func TestResourceDiskIOWirePreservesAbsentDirection(t *testing.T) { + zero := &unifiedresources.MetricValue{Value: 0, Unit: "bytes/s", Source: unifiedresources.SourceDocker} + for _, tc := range []struct { + name string + metrics *unifiedresources.ResourceMetrics + wire string + }{ + {"absent", nil, ``}, + {"read idle", &unifiedresources.ResourceMetrics{DiskRead: zero}, `{"readRate":0}`}, + {"write idle", &unifiedresources.ResourceMetrics{DiskWrite: zero}, `{"writeRate":0}`}, + {"both idle", &unifiedresources.ResourceMetrics{DiskRead: zero, DiskWrite: zero}, `{"readRate":0,"writeRate":0}`}, + } { + t.Run(tc.name, func(t *testing.T) { + read, write := monitorDiskIOMetricInput(tc.metrics) + frontend := models.ConvertResourceToFrontend(models.ResourceConvertInput{DiskReadRate: read, DiskWriteRate: write}) + if tc.wire == "" { + if frontend.DiskIO != nil { + t.Fatalf("absent IO became a payload: %+v", frontend.DiskIO) + } + return + } + wire, err := json.Marshal(frontend.DiskIO) + if err != nil { + t.Fatal(err) + } + if string(wire) != tc.wire { + t.Fatalf("wire = %s, want %s", wire, tc.wire) + } + }) + } +} diff --git a/internal/monitoring/issue1613_contract_test.go b/internal/monitoring/issue1613_contract_test.go index 01e69fed3..7211b51d0 100644 --- a/internal/monitoring/issue1613_contract_test.go +++ b/internal/monitoring/issue1613_contract_test.go @@ -138,7 +138,7 @@ func TestIssue1613NodeDoesNotGreyBetweenNinetySecondPolls(t *testing.T) { } } -func TestIssue1613WebsocketStateKeepsUnknownRatesNumeric(t *testing.T) { +func TestIssue1613WebsocketStateKeepsObservedZeroAndOmitsUnknownRates(t *testing.T) { monitor := &Monitor{ state: models.NewState(), resourceStore: &resourceOnlyStore{resources: []unifiedresources.Resource{ @@ -168,7 +168,7 @@ func TestIssue1613WebsocketStateKeepsUnknownRatesNumeric(t *testing.T) { t.Fatal(err) } wire := string(payload) - if !strings.Contains(wire, `"diskIO":{"readRate":0,"writeRate":0}`) { + if !strings.Contains(wire, `"diskIO":{"readRate":0}`) { t.Fatalf("websocket payload does not contain numeric valid zero disk rate: %s", wire) } if strings.Count(wire, `"diskIO"`) != 1 { diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 11a370b76..f0c9b0328 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -6181,10 +6181,7 @@ func monitorResourceToConvertInput(resource unifiedresources.Resource) models.Re input.HasNetwork = hasNetwork input.NetworkRX = rx input.NetworkTX = tx - hasDiskIO, diskRead, diskWrite := monitorDiskIOMetricInput(resource.Metrics) - input.HasDiskIO = hasDiskIO - input.DiskReadRate = diskRead - input.DiskWriteRate = diskWrite + input.DiskReadRate, input.DiskWriteRate = monitorDiskIOMetricInput(resource.Metrics) return input } @@ -6615,20 +6612,20 @@ func monitorNetworkMetricInput(metrics *unifiedresources.ResourceMetrics) (bool, return true, rx, tx } -func monitorDiskIOMetricInput(metrics *unifiedresources.ResourceMetrics) (bool, int64, int64) { +func monitorDiskIOMetricInput(metrics *unifiedresources.ResourceMetrics) (*int64, *int64) { if metrics == nil || (metrics.DiskRead == nil && metrics.DiskWrite == nil) { - return false, 0, 0 + return nil, nil } - - var read int64 - var write int64 + var read, write *int64 if metrics.DiskRead != nil { - read = int64(math.Round(metrics.DiskRead.Value)) + value := int64(math.Round(metrics.DiskRead.Value)) + read = &value } if metrics.DiskWrite != nil { - write = int64(math.Round(metrics.DiskWrite.Value)) + value := int64(math.Round(metrics.DiskWrite.Value)) + write = &value } - return true, read, write + return read, write } func monitorTemperature(resource unifiedresources.Resource) *float64 { diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index a4bf3c3af..732916aed 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -2347,10 +2347,15 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con containerIdentifier = payload.Name } if strings.TrimSpace(containerIdentifier) != "" { + readPresent, writePresent := payload.BlockIO.CounterPresence() metrics := models.IOMetrics{ NetworkIn: clampToInt64(payload.NetworkRXBytes), NetworkOut: clampToInt64(payload.NetworkTXBytes), Timestamp: receivedAt, + Presence: models.IOCounterPresence{ + Explicit: true, DiskRead: readPresent, DiskWrite: writePresent, + NetworkIn: true, NetworkOut: true, + }, } if payload.BlockIO != nil { metrics.DiskRead = clampToInt64(payload.BlockIO.ReadBytes) @@ -2633,16 +2638,9 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con } metricKey := fmt.Sprintf("docker:%s", container.ID) - var diskPercent float64 - if container.RootFilesystemBytes > 0 && container.WritableLayerBytes > 0 { - diskPercent = float64(container.WritableLayerBytes) / float64(container.RootFilesystemBytes) * 100 - if diskPercent > 100 { - diskPercent = 100 - } - } - - var diskReadRate float64 - var diskWriteRate float64 + // Layer sizes describe container images, not filesystem capacity. + // Missing rate observations must not become measured idle samples. + diskReadRate, diskWriteRate := -1.0, -1.0 if container.BlockIO != nil { if container.BlockIO.ReadRateBytesPerSecond != nil { diskReadRate = *container.BlockIO.ReadRateBytesPerSecond @@ -2655,7 +2653,6 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con if m.metricsHistory != nil { m.metricsHistory.AddGuestMetric(metricKey, "cpu", models.DockerContainerCPUCapacityPercent(container, host.CPUs), now) m.metricsHistory.AddGuestMetric(metricKey, "memory", container.MemoryPercent, now) - m.metricsHistory.AddGuestMetric(metricKey, "disk", diskPercent, now) if container.NetInRate >= 0 { m.metricsHistory.AddGuestMetric(metricKey, "netin", container.NetInRate, now) } @@ -2673,7 +2670,6 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con if m.metricsStore != nil { m.metricsStore.Write("dockerContainer", container.ID, "cpu", models.DockerContainerCPUCapacityPercent(container, host.CPUs), now) m.metricsStore.Write("dockerContainer", container.ID, "memory", container.MemoryPercent, now) - m.metricsStore.Write("dockerContainer", container.ID, "disk", diskPercent, now) if container.NetInRate >= 0 { m.metricsStore.Write("dockerContainer", container.ID, "netin", container.NetInRate, now) } diff --git a/internal/monitoring/monitor_unified_state_test.go b/internal/monitoring/monitor_unified_state_test.go index 9011a8e87..e920fec1e 100644 --- a/internal/monitoring/monitor_unified_state_test.go +++ b/internal/monitoring/monitor_unified_state_test.go @@ -165,7 +165,7 @@ func TestConvertResourcesForBroadcastCoalescesSplitHostResources(t *testing.T) { if resource.DiskIO == nil { t.Fatal("expected aggregate disk I/O rates in broadcast resource") } - if resource.DiskIO.ReadRate != 4096 || resource.DiskIO.WriteRate != 8192 { + if resource.DiskIO.ReadRate == nil || resource.DiskIO.WriteRate == nil || *resource.DiskIO.ReadRate != 4096 || *resource.DiskIO.WriteRate != 8192 { t.Fatalf("unexpected aggregate disk I/O rates: %+v", resource.DiskIO) } } diff --git a/internal/unifiedresources/metrics.go b/internal/unifiedresources/metrics.go index 6c50f57b1..399060c00 100644 --- a/internal/unifiedresources/metrics.go +++ b/internal/unifiedresources/metrics.go @@ -270,17 +270,8 @@ func metricsFromDockerContainer(ct models.DockerContainer, hostCPUs ...int) *Res percent := percentFromReportedPercent(ct.MemoryPercent) metrics.Memory = &MetricValue{Used: &ct.MemoryUsage, Total: &ct.MemoryLimit, Percent: percent, Unit: "bytes", Source: SourceDocker} } - if ct.RootFilesystemBytes > 0 { - used := ct.WritableLayerBytes - if used < 0 { - used = 0 - } - if used > ct.RootFilesystemBytes { - used = ct.RootFilesystemBytes - } - percent := clampMetricValue((float64(used)/float64(ct.RootFilesystemBytes))*100, 0, 100) - metrics.Disk = &MetricValue{Used: &used, Total: &ct.RootFilesystemBytes, Percent: percent, Unit: "bytes", Source: SourceDocker} - } + // Writable and root layer sizes are image metadata, not used/total + // filesystem capacity. Docker does not supply a capacity observation here. if ct.NetInRate > 0 { metrics.NetIn = &MetricValue{Value: ct.NetInRate, Unit: "bytes/s", Source: SourceDocker} } @@ -288,10 +279,10 @@ func metricsFromDockerContainer(ct models.DockerContainer, hostCPUs ...int) *Res metrics.NetOut = &MetricValue{Value: ct.NetOutRate, Unit: "bytes/s", Source: SourceDocker} } if ct.BlockIO != nil { - if ct.BlockIO.ReadRateBytesPerSecond != nil && *ct.BlockIO.ReadRateBytesPerSecond > 0 { + if ct.BlockIO.ReadRateBytesPerSecond != nil && *ct.BlockIO.ReadRateBytesPerSecond >= 0 && !math.IsInf(*ct.BlockIO.ReadRateBytesPerSecond, 0) { metrics.DiskRead = &MetricValue{Value: *ct.BlockIO.ReadRateBytesPerSecond, Unit: "bytes/s", Source: SourceDocker} } - if ct.BlockIO.WriteRateBytesPerSecond != nil && *ct.BlockIO.WriteRateBytesPerSecond > 0 { + if ct.BlockIO.WriteRateBytesPerSecond != nil && *ct.BlockIO.WriteRateBytesPerSecond >= 0 && !math.IsInf(*ct.BlockIO.WriteRateBytesPerSecond, 0) { metrics.DiskWrite = &MetricValue{Value: *ct.BlockIO.WriteRateBytesPerSecond, Unit: "bytes/s", Source: SourceDocker} } } diff --git a/internal/unifiedresources/metrics_test.go b/internal/unifiedresources/metrics_test.go index d99e54af5..b1513c437 100644 --- a/internal/unifiedresources/metrics_test.go +++ b/internal/unifiedresources/metrics_test.go @@ -1,12 +1,34 @@ package unifiedresources import ( + "math" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/internal/models" ) +func TestMetricsFromDockerContainerDistinguishesAbsentAndIdleIO(t *testing.T) { + for _, value := range []float64{0, 123, -1, math.NaN(), math.Inf(1)} { + ct := models.DockerContainer{BlockIO: &models.DockerContainerBlockIO{ReadRateBytesPerSecond: &value, WriteRateBytesPerSecond: &value}} + got := metricsFromDockerContainer(ct) + valid := value >= 0 && !math.IsInf(value, 0) + if valid { + if got.DiskRead == nil || got.DiskWrite == nil || got.DiskRead.Value != value || got.DiskWrite.Value != value { + t.Fatalf("lost measured rate %v: %+v", value, got) + } + } else if got.DiskRead != nil || got.DiskWrite != nil { + t.Fatalf("invalid rate %v became observation: %+v", value, got) + } + } + for _, io := range []*models.DockerContainerBlockIO{nil, {ReadBytes: 5000, WriteBytes: 7000}} { + got := metricsFromDockerContainer(models.DockerContainer{BlockIO: io}) + if got.DiskRead != nil || got.DiskWrite != nil { + t.Fatalf("absent rates became observations: %+v", got) + } + } +} + func TestMetricsFromDockerHostIncludesIORates(t *testing.T) { host := models.DockerHost{ CPUUsage: 12.5, @@ -447,8 +469,8 @@ func TestMetricsFromDockerContainerIncludesContainerIORates(t *testing.T) { if metrics.DiskWrite == nil || metrics.DiskWrite.Value != writeRate { t.Fatalf("expected diskWrite=%v, got %+v", writeRate, metrics.DiskWrite) } - if metrics.Disk == nil || metrics.Disk.Percent <= 0 { - t.Fatalf("expected non-zero disk usage metric, got %+v", metrics.Disk) + if metrics.Disk != nil { + t.Fatalf("container layer sizes are not filesystem capacity, got %+v", metrics.Disk) } } diff --git a/pkg/agents/docker/blockio_presence_test.go b/pkg/agents/docker/blockio_presence_test.go new file mode 100644 index 000000000..0e507b911 --- /dev/null +++ b/pkg/agents/docker/blockio_presence_test.go @@ -0,0 +1,32 @@ +package dockeragent + +import ( + "encoding/json" + "testing" +) + +func TestContainerBlockIOCounterPresenceWireCompatibility(t *testing.T) { + for _, tc := range []struct { + wire string + read, write bool + }{ + {`null`, false, false}, + {`{}`, false, false}, + {`{"readBytes":5000}`, true, false}, + {`{"writeBytes":7000}`, false, true}, + {`{"readBytes":5000,"writeBytes":7000}`, true, true}, + {`{"readBytesPresent":true,"writeBytesPresent":true}`, true, true}, + {`{"readBytes":5000,"readBytesPresent":false,"writeBytesPresent":true}`, false, true}, + } { + t.Run(tc.wire, func(t *testing.T) { + var io *ContainerBlockIO + if err := json.Unmarshal([]byte(tc.wire), &io); err != nil { + t.Fatal(err) + } + read, write := io.CounterPresence() + if read != tc.read || write != tc.write { + t.Fatalf("presence = %v/%v, want %v/%v", read, write, tc.read, tc.write) + } + }) + } +} diff --git a/pkg/agents/docker/report.go b/pkg/agents/docker/report.go index 04459ba3e..e23eb8697 100644 --- a/pkg/agents/docker/report.go +++ b/pkg/agents/docker/report.go @@ -124,8 +124,27 @@ type ContainerNetwork struct { // ContainerBlockIO summarises high-level block I/O metrics for a container. type ContainerBlockIO struct { - ReadBytes uint64 `json:"readBytes,omitempty"` - WriteBytes uint64 `json:"writeBytes,omitempty"` + ReadBytes uint64 `json:"readBytes,omitempty"` + WriteBytes uint64 `json:"writeBytes,omitempty"` + ReadBytesPresent *bool `json:"readBytesPresent,omitempty"` + WriteBytesPresent *bool `json:"writeBytesPresent,omitempty"` +} + +// CounterPresence distinguishes observed zero from an omitted direction. Older +// agents omitted zero values and presence, so only their positive counters are +// unambiguous. New agents carry explicit presence independently of counter value. +func (io *ContainerBlockIO) CounterPresence() (read, write bool) { + if io == nil { + return false, false + } + read, write = io.ReadBytes > 0, io.WriteBytes > 0 + if io.ReadBytesPresent != nil { + read = *io.ReadBytesPresent + } + if io.WriteBytesPresent != nil { + write = *io.WriteBytesPresent + } + return } // PodmanContainer carries metadata extracted from Podman-specific annotations. diff --git a/pkg/metrics/docker_observation_contract.go b/pkg/metrics/docker_observation_contract.go new file mode 100644 index 000000000..8b03199b5 --- /dev/null +++ b/pkg/metrics/docker_observation_contract.go @@ -0,0 +1,35 @@ +package metrics + +// Docker history written before explicit counter presence mixed unavailable +// readings with measured zero. Its disk percentage also measured image-layer +// composition rather than filesystem capacity. Keep those rows for retention +// and rollback, but never reinterpret them as observations under this contract. +// +// The app-container storage family is shared with other providers. Their valid +// new capacity readings remain supported. Public metric names are unchanged. +func hasDockerObservationContract(resourceType string) bool { + return resourceType == "dockercontainer" || resourceType == "docker" +} + +func storedObservationMetric(resourceType, metricType string) string { + if hasDockerObservationContract(resourceType) { + switch metricType { + case "disk", "diskread", "diskwrite": + return metricType + ".observed" + } + } + return metricType +} + +func projectDockerObservations(result map[string]map[string][]MetricPoint) { + for _, series := range result { + for _, metric := range []string{"disk", "diskread", "diskwrite"} { + delete(series, metric) + stored := metric + ".observed" + if points, ok := series[stored]; ok { + series[metric] = points + delete(series, stored) + } + } + } +} diff --git a/pkg/metrics/store.go b/pkg/metrics/store.go index 00281afe7..dfe48f58c 100644 --- a/pkg/metrics/store.go +++ b/pkg/metrics/store.go @@ -174,10 +174,11 @@ type SeriesKey struct { // NormalizedSeriesKey builds the SeriesKey the write path would store for the // given identifiers, so callers can match MaxTimestampsForTier results. func NormalizedSeriesKey(resourceType, resourceID, metricType string) SeriesKey { + resourceType = normalizeMetricResourceType(resourceType) return SeriesKey{ - ResourceType: normalizeMetricResourceType(resourceType), + ResourceType: resourceType, ResourceID: normalizeMetricIdentifier(resourceID), - MetricType: normalizeMetricType(metricType), + MetricType: storedObservationMetric(resourceType, normalizeMetricType(metricType)), } } @@ -723,7 +724,7 @@ func validateMetricWrite(resourceType, resourceID, metricType string, tier Tier) return "", "", "", false, fmt.Sprintf("unsupported metric tier %q", tier) } - return normalizedType, normalizedID, normalizedMetric, true, "" + return normalizedType, normalizedID, storedObservationMetric(normalizedType, normalizedMetric), true, "" } // Write adds a metric to the write buffer with the 'raw' tier by default @@ -1297,6 +1298,11 @@ func (s *Store) queryBatch( return map[string]map[string][]MetricPoint{}, nil } normalizedMetricTypes := normalizeMetricTypes(metricTypes) + if hasDockerObservationContract(resourceType) { + for i, metric := range normalizedMetricTypes { + normalizedMetricTypes[i] = storedObservationMetric(resourceType, metric) + } + } tiers := s.tierFallbacks(end.Sub(start)) if len(tiers) == 0 { @@ -1730,6 +1736,9 @@ func (s *Store) queryRetainedChunk(resourceType string, resourceIDs []string, me flushBucket() flushSeries() + if hasDockerObservationContract(resourceType) { + projectDockerObservations(result) + } return result, nil } diff --git a/pkg/metrics/store_docker_observation_contract_test.go b/pkg/metrics/store_docker_observation_contract_test.go new file mode 100644 index 000000000..cde253191 --- /dev/null +++ b/pkg/metrics/store_docker_observation_contract_test.go @@ -0,0 +1,148 @@ +package metrics + +import ( + "fmt" + "testing" + "time" +) + +func TestDockerObservationContractSeparatesLegacyAcrossRetainedReads(t *testing.T) { + for _, family := range []string{"dockerContainer", "docker"} { + t.Run(family, func(t *testing.T) { + store, err := NewStore(DefaultConfig(t.TempDir())) + if err != nil { + t.Fatal(err) + } + defer store.Close() + ts := time.Now().Add(-10 * time.Minute).Truncate(time.Minute) + // Seed the old physical schema directly. New public writes must not grant + // these ambiguous historical rows an observation provenance retroactively. + for _, id := range []string{"a", "b", "legacy-only"} { + for _, metric := range []string{"disk", "diskread", "diskwrite"} { + for _, tier := range []Tier{TierRaw, TierMinute, TierHourly} { + _, err := store.db.Exec(`INSERT INTO metrics(resource_type,resource_id,metric_type,value,timestamp,tier) VALUES(?,?,?,?,?,?)`, normalizeMetricResourceType(family), id, metric, 99, ts.Unix(), string(tier)) + if err != nil { + t.Fatal(err) + } + } + } + } + for _, id := range []string{"a", "b"} { + // Exercise the buffered, synchronous and bounded writer entry points. + store.Write(family, id, "diskread", 0, ts) + store.WriteBatchSync([]WriteMetric{{ResourceType: family, ResourceID: id, MetricType: "diskwrite", Value: 0, Timestamp: ts, Tier: TierRaw}}) + store.WriteBatchBounded([]WriteMetric{{ResourceType: family, ResourceID: id, MetricType: "disk", Value: 25, Timestamp: ts, Tier: TierRaw}}) + store.Write(family, id, "cpu", 0, ts) + } + store.Flush() + // Both generations roll up independently. Old minute/hourly data must + // neither replace the corrected zeros nor contaminate their averages. + if !store.rollupTierWindow(TierRaw, TierMinute, 60, ts.Unix()-60, ts.Unix()+120) { + t.Fatal("rollup failed") + } + coverage, err := store.MaxTimestampsForTier(TierRaw) + if err != nil { + t.Fatal(err) + } + if !coverage[NormalizedSeriesKey(family, "a", "diskread")].Equal(ts) { + t.Fatalf("physical coverage mismatch: %+v", coverage) + } + for _, step := range []int64{0, 60} { + t.Run(fmt.Sprintf("step-%d", step), func(t *testing.T) { + start, end := ts.Add(-2*time.Hour), ts.Add(time.Hour) + assert := func(series map[string][]MetricPoint, legacy bool) { + t.Helper() + for _, metric := range []string{"disk", "diskread", "diskwrite"} { + points := series[metric] + if legacy { + if len(points) > 0 { + t.Fatalf("legacy %s leaked: %+v", metric, points) + } + continue + } + want := 0.0 + if metric == "disk" { + want = 25 + } + if len(points) != 1 || points[0].Value != want || points[0].Min != want || points[0].Max != want { + t.Fatalf("%s observation contaminated: %+v", metric, points) + } + if _, exists := series[metric+".observed"]; exists { + t.Fatalf("physical metric leaked: %+v", series) + } + } + } + all, err := store.QueryAll(family, "a", start, end, step) + if err != nil { + t.Fatal(err) + } + assert(all, false) + if len(all["cpu"]) != 1 || all["cpu"][0].Value != 0 { + t.Fatalf("unrelated zero lost: %+v", all) + } + batch, err := store.QueryAllBatch(family, []string{"a", "b", "legacy-only"}, start, end, step) + if err != nil { + t.Fatal(err) + } + assert(batch["a"], false) + assert(batch["b"], false) + assert(batch["legacy-only"], true) + selected, err := store.QueryMetricTypesBatch(family, []string{"a", "b", "legacy-only"}, []string{"disk", "diskread", "diskwrite"}, start, end, step) + if err != nil { + t.Fatal(err) + } + assert(selected["a"], false) + assert(selected["b"], false) + assert(selected["legacy-only"], true) + for _, metric := range []string{"disk", "diskread", "diskwrite"} { + points, err := store.Query(family, "a", metric, start, end, step) + if err != nil { + t.Fatal(err) + } + if len(points) != 1 || points[0].Value != all[metric][0].Value { + t.Fatalf("selected %s differs: %+v", metric, points) + } + old, err := store.Query(family, "legacy-only", metric, start, end, step) + if err != nil || len(old) != 0 { + t.Fatalf("legacy selected %s leaked: %+v %v", metric, old, err) + } + } + }) + } + var legacyCount int + err = store.db.QueryRow(`SELECT COUNT(*) FROM metrics WHERE resource_type=? AND metric_type IN ('disk','diskread','diskwrite')`, normalizeMetricResourceType(family)).Scan(&legacyCount) + if err != nil || legacyCount != 27 { + t.Fatalf("legacy history was changed: count=%d err=%v", legacyCount, err) + } + }) + } +} + +func TestDockerObservationContractLeavesOtherFamiliesUnchanged(t *testing.T) { + store, err := NewStore(DefaultConfig(t.TempDir())) + if err != nil { + t.Fatal(err) + } + defer store.Close() + ts := time.Now().Truncate(time.Second) + for _, family := range []string{"vm", "ct", "agent", "dockerHost", "disk", "storage", "k8s"} { + for _, metric := range []string{"disk", "diskread", "diskwrite"} { + store.WriteWithTier(family, "one", metric, 0, ts, TierRaw) + } + } + store.Flush() + for _, family := range []string{"vm", "ct", "agent", "dockerHost", "disk", "storage", "k8s"} { + all, err := store.QueryAll(family, "one", ts.Add(-time.Second), ts.Add(time.Second), 0) + if err != nil { + t.Fatal(err) + } + for _, metric := range []string{"disk", "diskread", "diskwrite"} { + if len(all[metric]) != 1 || all[metric][0].Value != 0 { + t.Fatalf("%s/%s zero changed: %+v", family, metric, all) + } + if NormalizedSeriesKey(family, "one", metric).MetricType != metric { + t.Fatal("unrelated physical key changed") + } + } + } +} diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 08bfcc31d..0697340f4 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -225,6 +225,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "internal/config/host_continuity_test.go", "internal/models/metrics_types_test.go", "internal/monitoring/availability_probe_agent_test.go", + "internal/monitoring/docker_metric_presence_test.go", "internal/monitoring/monitor_host_agent_removal_lifecycle_test.go", "internal/monitoring/monitor_host_agents_test.go", "scripts/installtests/agent_state_dir_lifecycle_test.go", @@ -354,6 +355,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "internal/dockeragent/agent_collect_test.go", "internal/dockeragent/agent_cpu_test.go", "internal/dockeragent/agent_internal_test.go", + "internal/dockeragent/blockio_presence_test.go", "internal/dockeragent/swarm_coverage_test.go", ], } @@ -451,6 +453,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "internal/config/host_continuity_test.go", "internal/models/metrics_types_test.go", "internal/monitoring/availability_probe_agent_test.go", + "internal/monitoring/docker_metric_presence_test.go", "internal/monitoring/monitor_host_agent_removal_lifecycle_test.go", "internal/monitoring/monitor_host_agents_test.go", "scripts/installtests/agent_state_dir_lifecycle_test.go", @@ -481,6 +484,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "test_prefixes": [], "exact_files": [ "internal/config/host_continuity_test.go", + "internal/monitoring/docker_metric_presence_test.go", "internal/monitoring/issue1485_unraid_lifecycle_test.go", "internal/monitoring/issue1595_collection_trust_test.go", "internal/monitoring/monitor_docker_test.go", diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 5823d4d78..197272676 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -4327,6 +4327,7 @@ class SubsystemLookupTest(unittest.TestCase): "internal/config/host_continuity_test.go", "internal/models/metrics_types_test.go", "internal/monitoring/availability_probe_agent_test.go", + "internal/monitoring/docker_metric_presence_test.go", "internal/monitoring/monitor_host_agent_removal_lifecycle_test.go", "internal/monitoring/monitor_host_agents_test.go", "scripts/installtests/agent_state_dir_lifecycle_test.go", @@ -4349,6 +4350,7 @@ class SubsystemLookupTest(unittest.TestCase): monitoring_match["verification_requirement"]["exact_files"], [ "internal/config/host_continuity_test.go", + "internal/monitoring/docker_metric_presence_test.go", "internal/monitoring/issue1485_unraid_lifecycle_test.go", "internal/monitoring/issue1595_collection_trust_test.go", "internal/monitoring/monitor_docker_test.go", From 0fcb2ee147354de770dfc4b0b9672d8c2c9dceb2 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:05:01 +0100 Subject: [PATCH 2/9] Align partial I/O regression expectations A missing direction cannot establish aggregate throughput. Update the remaining coverage cases to expect an unavailable total, consistent with the observation contract and user-visible missing readings. --- .../__tests__/agentMachineTableModel.coverage2.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend-modern/src/features/standalone/__tests__/agentMachineTableModel.coverage2.test.ts b/frontend-modern/src/features/standalone/__tests__/agentMachineTableModel.coverage2.test.ts index f7f382694..7edac2742 100644 --- a/frontend-modern/src/features/standalone/__tests__/agentMachineTableModel.coverage2.test.ts +++ b/frontend-modern/src/features/standalone/__tests__/agentMachineTableModel.coverage2.test.ts @@ -332,20 +332,20 @@ describe('agentMachineTableModel coverage2', () => { ).toBe(800); }); - it('returns read only when write is absent', () => { + it('leaves the total unavailable when write is absent', () => { expect( getAgentMachineDiskIOTotal( resource({ diskIO: { readRate: 500 } as unknown as ResourceDiskIO }), ), - ).toBe(500); + ).toBeUndefined(); }); - it('returns write only when read is absent', () => { + it('leaves the total unavailable when read is absent', () => { expect( getAgentMachineDiskIOTotal( resource({ diskIO: { writeRate: 300 } as unknown as ResourceDiskIO }), ), - ).toBe(300); + ).toBeUndefined(); }); it('returns undefined when both rates are absent', () => { From 355ac1f0a481d6dbc9a7ff3977bced0956711979 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:25:00 +0100 Subject: [PATCH 3/9] Resolve configuration reads from current inventory Native container config reads confused missing session discovery with a missing resource and could reuse stale placement. Read identity and capability from canonical inventory while preserving explicit query restrictions and existing action authority. Keep unavailable providers, unsupported adapters and empty observations distinct from resource absence, with the tool error bit preserved. --- .../PATROL_ASSISTANT_CUSTOMER_JOURNEY.md | 65 ++++++++ docs/release-control/v6/internal/status.json | 2 +- .../v6/internal/subsystems/ai-runtime.md | 13 ++ frontend-modern/browser-verification.json | 53 +++--- internal/ai/tools/tools_query.go | 149 +++++++++-------- internal/ai/tools/tools_query_config_test.go | 155 ++++++++++++++++++ 6 files changed, 331 insertions(+), 106 deletions(-) diff --git a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md index 24ec1eb30..49e1259cc 100644 --- a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md +++ b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md @@ -1494,3 +1494,68 @@ evidence were exercised. The source-bound receipt is has type and selector coverage, not an active-route browser claim. Controlled responses qualify rendering only. Landing checks remain separate from the unperformed diagnosis, approved/rejected action and recovery qualifications. + +### Configuration-read correction plan + +A successful canonical container get followed by a false config `not found` +result is a source contract defect. Native configuration reads must use current +canonical inventory for identity and provider capability. Optional session +resolution preserves continuity for later actions, not proof of existence. +Explicit query restrictions must be checked before registering or refreshing a +resource. Unsupported adapters, missing configuration providers, unavailable +placement and empty provider responses remain distinct from missing inventory. +No action validation or native log-read authority changes in this slice. + +Regression matrix: TrueNAS config with absent, empty and existing session +context, canonical identity across aliases, explicit query denial without a +provider call, Docker unsupported capability, genuinely missing inventory, +unavailable placement, provider failure and nil provider response. Reproduce +the failing cases before changing runtime code. Run affected Go tools checks +and focused race coverage on pulse-dev. + +Browser matrix after the final build: `/patrol` Assistant tool result details +at 1440x1000, 900x1000 and 390x1000, available configuration, unsupported +capability, true missing resource and denied/provider-failed results. Exercise +open/closed details, hover and keyboard focus, Enter/Space, deepest output +scrolling, Escape, and persisted/reloaded evidence. Use captured actual tool +results to qualify rendering without claiming model diagnosis or native +provider integration. No autonomous subscription retry or separately billed +provider request is part of this correction. + +### Configuration-read correction qualification + +The baseline reproduced absent/empty session failures, stale session placement +and false not-found results after successful canonical gets. The corrected +read path uses canonical resource identity and current provider placement. It +checks explicit query restrictions before registration and preserves an existing +query-only session's action limits. Unsupported configuration, unavailable +provider/placement and nil provider responses carry explicit reasons and the +tool error bit. Unavailable inventory and missing read state also remain failures +rather than evidence of resource absence. Actual inventory absence remains the +existing not-found lookup result. + +Fourteen focused contract cases pass with strict resolution enabled. The +existing native-config regression, full tools package and focused race proof +passed on pulse-dev with Go1.26.8. The final-source Pro binary SHA256 is +`552699cdf2e61a4ca1cea2ac5ef4e065735cbd1dbca01665456e184bd4fc3533`. +It is installed only in the existing local dev stack. No production agent or +provider configuration was changed. + +Playwright passed on `/patrol` at 1440x1000, 900x1000 and 390x1000. Eight actual +tool results were replayed and inspected, including successful, unavailable, +missing, denied and failed reads. Expanded inputs/outputs, keyboard toggles, +scrolling, Escape, reload and controlled session restoration preserve exact +evidence and error state. Controlled session responses prove rendering and +reload behavior, not server persistence or a new model/native-provider result. +The source-bound browser receipt records those limits. Private artifacts are +under `/Volumes/Development/pulse/tmp/patrol-config-read-contract/` and worker +logs under `/opt/pulse-release-worker/patrol-config-read-proof/`. + +PR1935's Docker correction required two legacy partial-total test expectations +to be updated in `0fcb2ee147354de770dfc4b0b9672d8c2c9dceb2`. The focused 55-test +file and scoped hook passed. Its latest CI has no failures and remains pending +completion. The configuration correction still requires its own landing checks. +Real-model retest, temporal/storage interpretation, approved and rejected +action outcomes and independent recovery proof remain open. Autonomous +subscription refusal and separately billed provider approval boundaries remain +unchanged. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index f8cbf9237..21f5279cf 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -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. Exact regression, browser and delivery state is recorded in the plan. 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, config-read semantics, 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. Exact regression, browser and delivery state is recorded in the plan. 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", diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index a8001aa9b..3e851d1da 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -25,6 +25,19 @@ that same result. Successful reads retain their content and execution provenance ## Purpose +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. +Query restrictions on both the supplied reference and canonical identity are +checked before registration, and an existing session's allowed actions are not +expanded by a read. Unsupported adapters, missing providers, incomplete placement +and nil provider observations retain known resource identity and an explicit +unavailability reason with the shared tool error bit. They cannot count as a +successful configuration read. Actual inventory absence remains distinct. +`TestAppContainerConfigObservationContract` exercises these boundaries with +strict resolution enabled. This read correction does not relax action or native +log validation and does not qualify autonomous diagnosis or recovery. + The published Patrol qualification schema must accept the fault injectors used by the executable catalogue. `TestCatalogFaultInjectorsMatchPublishedSchema` checks the actual scenario faults against the schema enum, including the diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index dd102f878..4e9b5706c 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,34 +1,21 @@ { "version": 1, - "base_sha": "6b0abc3bee9ffa81f6ab298b5b67ee11369688a0", - "verified_at": "2026-09-06T11:20:14.502Z", + "base_sha": "0fcb2ee147354de770dfc4b0b9672d8c2c9dceb2", + "verified_at": "2026-09-06T12:13:06.548Z", "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx", - "frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts", - "frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx", - "frontend-modern/src/features/standalone/AgentsMachinesTable.tsx", - "frontend-modern/src/features/standalone/agentMachineTableModel.ts", - "frontend-modern/src/hooks/useColumnVisibility.ts", - "frontend-modern/src/hooks/useUnifiedResources.ts", - "frontend-modern/src/types/resource.ts", - "frontend-modern/src/utils/format.ts" - ], - "content_sha256": { - "frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx": "f183ef158f646d52b9b2ff6c3af028d7b9b993a27542690ad664ab84f93d0d73", - "frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts": "a3ff0b74581e769db70b12dba94e30324aa52ccbb7242b7513cf82b8f8e2fd5e", - "frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx": "8957154b965f4e4240e7c19888fada63dcaf518a9e0e3a0e384b184ebaf4e716", - "frontend-modern/src/features/standalone/AgentsMachinesTable.tsx": "4342ba59fe9dbd33ae85131a2da45ac7ebeb7efe61457b43ce32640109bd3214", - "frontend-modern/src/features/standalone/agentMachineTableModel.ts": "ae419b49a1d761910612bf95b82e9c6226374c648d48d252259ed70b30b955c0", - "frontend-modern/src/hooks/useColumnVisibility.ts": "0112d88ca35fc0f1f3866070aa0913c706370bd6c0fb954afb7a89a34bd25cf3", - "frontend-modern/src/hooks/useUnifiedResources.ts": "4a434a0c0d2f42c1ee9dbed9ca3353ee5978821df2d9d725db1f4904f7a77b9e", - "frontend-modern/src/types/resource.ts": "a4d45afd0a4797ffe7075e8c4b0890e59f573ea8503a31eee68e22d2843cedd8", - "frontend-modern/src/utils/format.ts": "e0db6c68c1219481a9a3562239b35027916a04b1af6ec8df6fce0534a54ef8db" + "changed_paths": [], + "content_sha256": {}, + "backend_content_sha256": { + "internal/ai/tools/tools_query.go": "e9411606cdf96dec84882d0649ee69fb935b36086f0b5682d98fe3ee184f6841" + }, + "binary_sha256": "552699cdf2e61a4ca1cea2ac5ef4e065735cbd1dbca01665456e184bd4fc3533", + "rendering_content_sha256": { + "frontend-modern/src/components/AI/Chat/hooks/useChat.ts": "0b56b7a56e35d51ca96f0e126dd493b3164aa9e0ad4d8ae24bcf3af7a574b97c", + "frontend-modern/src/components/AI/Chat/ChatMessages.tsx": "9672f7608d1e3a531c73cba20fd4a78752316783212afd0c292ddfd11d2bf371", + "frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx": "cc7bd548a418c3863486f0fe987c5c3110c2f6cdfa70b630b2ec05fb794d60d6" }, "routes": [ - "/docker", - "/patrol", - "/standalone/machines" + "/patrol" ], "viewports": [ { @@ -45,15 +32,13 @@ } ], "states": [ - "Live Docker container Overview and retained History against rebuilt Pro backend 59f05f954ff8080bd3e8f3054b2b059281c49172ee771b2e454c255241158a4a. Public history excludes legacy capacity and ambiguous I/O, while new observed rates remain available. Binary hash checked before and after.", - "Controlled Docker history loading, empty/unavailable, observed read zero with absent write, and error states. Controlled host partial read/write in Docker Overview and Machines. Missing directions display a dash and observed zero displays 0 B/s.", - "Controlled Assistant history tool completion on /patrol preserves zero read samples and an empty write series. This qualifies rendering only. Provider calls and infrastructure mutations were blocked. The artificial route warning is not provider health evidence.", - "Machines Disk I/O column enabled from fresh preferences, tooltip open/closed and persisted after reload at desktop. Existing responsive policy hides the column at 900 and 390 pixels. The unused UnifiedResourceHostTableCard has type and selector coverage, not an active-route browser claim." + "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." ], "interactions": [ - "Container selection, Overview/History switching, keyboard focus, chart hover, scroll to deepest history, collapse and reopen, reload and history re-entry at 1440, 900 and 390 pixels. Inspected actual pixels for placement, wrapping and scrolling.", - "Machines View > Columns > Disk I/O, selected state, Escape from nested picker and parent, hover and keyboard focus on I/O tooltip, Escape and outside dismissal, reload preserving enabled column. Docker host Overview inspected at all three widths.", - "Assistant tool details expanded and collapsed with Enter and Space, output scrolled and inspected, and Escape dismissal at all widths. No action or model diagnosis was simulated as qualification.", - "Private final-source evidence: /Volumes/Development/pulse/tmp/patrol-docker-observed-metrics/browser, evidence-states and host-states. All three Playwright scripts passed after the final source edit. Runtime source hashes are recorded in runtime-binding.json." + "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." ] } diff --git a/internal/ai/tools/tools_query.go b/internal/ai/tools/tools_query.go index 7654a9f8e..9b49f6a6f 100644 --- a/internal/ai/tools/tools_query.go +++ b/internal/ai/tools/tools_query.go @@ -5276,110 +5276,117 @@ func (e *PulseToolExecutor) executeGetResourceConfig(ctx context.Context, args m } func (e *PulseToolExecutor) executeNativeAppContainerConfig(ctx context.Context, resourceRef string) (CallToolResult, error) { - if e.appContainerConfigProvider == nil { - return NewTextResult("App-container configuration not available."), nil + validation := e.validateResolvedResource(resourceRef, "query", true) + if validation.ErrorMsg != "" { + return NewErrorResult(fmt.Errorf("%s", validation.ErrorMsg)), nil + } + if validation.Resource != nil && validation.Resource.GetKind() != "app-container" { + return NewErrorResult(fmt.Errorf("resource '%s' is %q, not app-container", resourceRef, validation.Resource.GetKind())), nil + } + if e.unifiedResourceProvider == nil { + return NewErrorResult(fmt.Errorf("current app-container inventory is unavailable")), nil } rs, err := e.readStateForControl() if err != nil { - return NewTextResult("State information not available."), nil + return NewErrorResult(fmt.Errorf("current app-container state is unavailable: %w", err)), nil } governance := newGovernedQueryMetadataResolver(rs) - var resource unifiedresources.Resource - var found bool - if validation := e.validateResolvedResource(resourceRef, "query", true); validation.Resource != nil { - if matched, _, ok := findCanonicalAppContainerResource(e.unifiedResourceProvider, resourceRef); ok { - resource = matched - found = true - } - } + resource, providerID, found := findCanonicalAppContainerResource(e.unifiedResourceProvider, resourceRef) if !found { - var containerID string - resource, containerID, found = findCanonicalAppContainerResource(e.unifiedResourceProvider, resourceRef) - if !found { - return NewJSONResult(map[string]interface{}{ - "error": "not_found", - "resource_id": resourceRef, - "type": "app-container", - }), nil - } - if reg, ok := resolvedAppContainerRegistration(resource); ok { - e.registerResolvedResourceWithExplicitAccess(reg) - } - _ = containerID + return NewJSONResult(map[string]interface{}{ + "error": "not_found", + "resource_id": resourceRef, + "type": "app-container", + }), nil } - validation := e.validateResolvedResource(resourceRef, "query", true) - if validation.Resource == nil { - if validation.ErrorMsg != "" { - return NewErrorResult(fmt.Errorf("%s", validation.ErrorMsg)), nil - } - return NewErrorResult(fmt.Errorf("app-container not found: %s", resourceRef)), nil + // Inventory owns read identity and capability. Optional session discovery + // supplies restrictions and continuity, not proof that the resource exists. + resourceID := canonicalAppContainerID(resource) + canonicalValidation := e.validateResolvedResource(resourceID, "query", true) + if canonicalValidation.ErrorMsg != "" { + return NewErrorResult(fmt.Errorf("%s", canonicalValidation.ErrorMsg)), nil } - if validation.ErrorMsg != "" { - return NewErrorResult(fmt.Errorf("%s", validation.ErrorMsg)), nil + if canonicalValidation.Resource != nil && canonicalValidation.Resource.GetKind() != "app-container" { + return NewErrorResult(fmt.Errorf("resource '%s' is %q, not app-container", resourceID, canonicalValidation.Resource.GetKind())), nil } - resolved := validation.Resource - if resolved.GetKind() != "app-container" { - return NewErrorResult(fmt.Errorf("resource '%s' is %q, not app-container", resourceRef, resolved.GetKind())), nil + platform := canonicalAppContainerAdapter(resource) + unavailable := func(reason, message string) CallToolResult { + return NewJSONResultWithIsError(map[string]interface{}{ + "available": false, "reason": reason, "message": message, + "resource_id": resourceID, "type": "app-container", "platform": platform, + }, true) } - if !strings.EqualFold(strings.TrimSpace(resolved.GetAdapter()), "truenas") { - return NewTextResult("App-container configuration not available."), nil + if platform != "truenas" { + return unavailable("unsupported_adapter", "The resource exists, but its adapter does not support configuration reads."), nil + } + if e.appContainerConfigProvider == nil { + return unavailable("provider_unavailable", "The resource exists, but its configuration provider is unavailable."), nil + } + reg, ok := resolvedAppContainerRegistration(resource) + if !ok { + return unavailable("resource_context_unavailable", "The resource exists, but its current provider identity or placement is incomplete."), nil + } + // Do not overwrite an existing session's allowed actions during a read. + if validation.Resource == nil && canonicalValidation.Resource == nil { + e.registerResolvedResourceWithExplicitAccess(reg) } result, err := e.appContainerConfigProvider.GetConfig(ctx, AppContainerConfigRequest{ OrgID: e.orgID, - ResourceID: strings.TrimSpace(resolved.GetResourceID()), - ProviderUID: strings.TrimSpace(resolved.GetProviderUID()), + ResourceID: resourceID, + ProviderUID: providerID, Name: resourceDisplayName(resource), - Host: strings.TrimSpace(resolved.GetTargetHost()), - Platform: "truenas", + Host: canonicalAppContainerHost(resource), + Platform: platform, }) if err != nil { return NewErrorResult(err), nil } + if result == nil { + return unavailable("empty_provider_response", "The resource exists, but the provider returned no configuration observation."), nil + } response := EmptyAppContainerConfigResponse() - if result != nil { - response.GovernedResourceMetadata = governance.Resolve(result.Name, result.ResourceID, result.ProviderUID) - response.Type = "app-container" - response.ID = result.ProviderUID - if response.ID == "" { - response.ID = strings.TrimSpace(result.ResourceID) - } - response.Name = result.Name - response.Host = result.Host - response.Platform = result.Platform - response.Status = result.Status - response.Version = result.Version - response.HumanVersion = result.HumanVersion - response.Notes = result.Notes - response.CustomApp = result.CustomApp - response.UpgradeAvailable = result.UpgradeAvailable - response.ImageUpdatesAvailable = result.ImageUpdatesAvailable - response.ContainerCount = result.ContainerCount - response.UsedHostIPs = append([]string{}, result.UsedHostIPs...) - response.Images = append([]string{}, result.Images...) - response.Ports = append([]PortInfo{}, result.Ports...) - response.Networks = append([]NetworkInfo{}, result.Networks...) - response.Mounts = append([]MountInfo{}, result.Mounts...) - response.Containers = append([]AppContainerConfigContainer{}, result.Containers...) - } + response.GovernedResourceMetadata = governance.Resolve(result.Name, result.ResourceID, result.ProviderUID) + response.Type = "app-container" + response.ID = result.ProviderUID if response.ID == "" { - response.ID = strings.TrimSpace(resolved.GetProviderUID()) + response.ID = strings.TrimSpace(result.ResourceID) + } + response.Name = result.Name + response.Host = result.Host + response.Platform = result.Platform + response.Status = result.Status + response.Version = result.Version + response.HumanVersion = result.HumanVersion + response.Notes = result.Notes + response.CustomApp = result.CustomApp + response.UpgradeAvailable = result.UpgradeAvailable + response.ImageUpdatesAvailable = result.ImageUpdatesAvailable + response.ContainerCount = result.ContainerCount + response.UsedHostIPs = append([]string{}, result.UsedHostIPs...) + response.Images = append([]string{}, result.Images...) + response.Ports = append([]PortInfo{}, result.Ports...) + response.Networks = append([]NetworkInfo{}, result.Networks...) + response.Mounts = append([]MountInfo{}, result.Mounts...) + response.Containers = append([]AppContainerConfigContainer{}, result.Containers...) + if response.ID == "" { + response.ID = providerID } if response.Name == "" { - response.Name = resolvedResourceDisplayName(resolved) + response.Name = resourceDisplayName(resource) } if response.Host == "" { - response.Host = strings.TrimSpace(resolved.GetTargetHost()) + response.Host = canonicalAppContainerHost(resource) } if response.Platform == "" { - response.Platform = strings.TrimSpace(resolved.GetAdapter()) + response.Platform = platform } if response.GovernedResourceMetadata.Policy == nil && response.AISafeSummary == "" { - response.GovernedResourceMetadata = governance.Resolve(response.Name, strings.TrimSpace(resolved.GetResourceID()), response.ID) + response.GovernedResourceMetadata = governance.Resolve(response.Name, resourceID, response.ID) } return NewJSONResult(response.NormalizeCollections()), nil diff --git a/internal/ai/tools/tools_query_config_test.go b/internal/ai/tools/tools_query_config_test.go index b6f338a3e..37e78df67 100644 --- a/internal/ai/tools/tools_query_config_test.go +++ b/internal/ai/tools/tools_query_config_test.go @@ -3,13 +3,18 @@ package tools import ( "context" "encoding/json" + "errors" + "strings" "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) type stubAppContainerConfigProvider struct { calls []AppContainerConfigRequest result *AppContainerConfigResult err error + empty bool } func (s *stubAppContainerConfigProvider) GetConfig(_ context.Context, req AppContainerConfigRequest) (*AppContainerConfigResult, error) { @@ -17,6 +22,9 @@ func (s *stubAppContainerConfigProvider) GetConfig(_ context.Context, req AppCon if s.err != nil { return nil, s.err } + if s.empty { + return nil, nil + } if s.result == nil { return &AppContainerConfigResult{ ResourceID: req.ResourceID, @@ -38,6 +46,153 @@ func (s *stubAppContainerConfigProvider) GetConfig(_ context.Context, req AppCon return &result, nil } +func TestAppContainerConfigObservationContract(t *testing.T) { + t.Setenv("PULSE_STRICT_RESOLUTION", "true") + for _, tc := range []struct { + name, session, reason, failure string + missing, docker, noProvider, noHost, empty bool + noInventory, noReadState bool + }{ + {name: "without session"}, + {name: "empty session", session: "empty"}, + {name: "discovered session", session: "discovered"}, + {name: "stale placement", session: "stale"}, + {name: "unsupported adapter", docker: true, reason: "unsupported_adapter"}, + {name: "unavailable provider", noProvider: true, reason: "provider_unavailable"}, + {name: "missing placement", noHost: true, reason: "resource_context_unavailable"}, + {name: "empty provider response", empty: true, reason: "empty_provider_response"}, + {name: "provider failed", failure: "provider read failed"}, + {name: "resource absent", missing: true}, + {name: "inventory unavailable", noInventory: true, failure: "inventory is unavailable"}, + {name: "read state unavailable", noReadState: true, failure: "state is unavailable"}, + {name: "query denied", session: "denied", failure: "not permitted"}, + {name: "canonical query denied through prefix", session: "canonical-denied", failure: "not permitted"}, + } { + t.Run(tc.name, func(t *testing.T) { + registry := newTrueNASUnifiedQueryProvider(t) + resource, _, found := findCanonicalAppContainerResource(registry, "nextcloud") + if !found { + t.Fatal("missing canonical fixture") + } + if tc.docker { + resource.TrueNAS = nil + resource.Tags = nil + } + if tc.noHost { + resource.ParentName = "" + resource.Identity.Hostnames = nil + } + provider := &stubUnifiedResourceProvider{resources: []unifiedresources.Resource{resource}} + config := &stubAppContainerConfigProvider{empty: tc.empty} + if tc.failure == "provider read failed" { + config.err = errors.New(tc.failure) + } + cfg := ExecutorConfig{UnifiedResourceProvider: provider, ReadState: registry.ResourceRegistry} + if tc.noInventory { + cfg.UnifiedResourceProvider = nil + } + if tc.noReadState { + cfg.ReadState = nil + } + if !tc.noProvider { + cfg.AppContainerConfigProvider = config + } + executor := NewPulseToolExecutor(cfg) + ref := "Nextcloud" + if tc.session != "" { + resolved := &mockResolvedContext{resources: map[string]ResolvedResourceInfo{}, aliases: map[string]ResolvedResourceInfo{}} + executor.SetResolvedContext(resolved) + switch tc.session { + case "discovered": + reg, ok := resolvedAppContainerRegistration(resource) + if !ok { + t.Fatal("fixture registration unavailable") + } + resolved.AddResolvedResource(reg) + case "stale", "denied", "canonical-denied": + cached := &mockResource{resourceID: resource.ID, kind: "app-container", adapter: "docker", targetHost: "stale-host", providerUID: "stale-id", allowedActions: []string{"query"}} + if tc.session != "stale" { + cached.allowedActions = []string{"logs"} + } + resolved.resources[resource.ID] = cached + if tc.session == "canonical-denied" { + ref = "next" + } else { + resolved.aliases[ref] = cached + } + } + } + if tc.missing { + ref = "absent-container" + } + // Inventory get succeeds independently of optional session state. + if tc.session == "" && !tc.missing && !tc.noInventory && !tc.noReadState { + got, err := executor.executeGetResource(context.Background(), map[string]interface{}{"resource_type": "app-container", "resource_id": ref}) + if err != nil || got.IsError || strings.Contains(got.Content[0].Text, "not_found") { + t.Fatalf("canonical get failed: %+v %v", got, err) + } + } + args := map[string]interface{}{"action": "config", "resource_type": "app-container", "resource_id": ref} + result, err := executor.executeQuery(context.Background(), args) + if err != nil { + t.Fatal(err) + } + evidence, err := json.Marshal(map[string]interface{}{"case": tc.name, "input": args, "result": result}) + if err != nil { + t.Fatal(err) + } + t.Logf("CONFIG_EVIDENCE %s", evidence) + wantError := tc.failure != "" || tc.reason != "" + if result.IsError != wantError { + t.Fatalf("read error bit=%v, want %v: %+v", result.IsError, wantError, result) + } + if tc.failure != "" { + if !result.IsError || !strings.Contains(result.Content[0].Text, tc.failure) { + t.Fatalf("expected %q failure, got %+v", tc.failure, result) + } + } else { + var response map[string]interface{} + if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil { + t.Fatal(err) + } + switch { + case tc.missing: + if response["error"] != "not_found" { + t.Fatalf("expected true absence, got %+v", response) + } + case tc.reason != "": + if response["available"] != false || response["reason"] != tc.reason || response["resource_id"] != resource.ID { + t.Fatalf("unavailable configuration lost identity or reason: %+v", response) + } + default: + if response["id"] != appContainerProviderID(resource) || response["host"] != canonicalAppContainerHost(resource) || response["platform"] != "truenas" { + t.Fatalf("incorrect config identity: %+v", response) + } + } + } + wantCalls := 1 + if tc.missing || tc.docker || tc.noProvider || tc.noHost || tc.noInventory || tc.noReadState || strings.Contains(tc.session, "denied") { + wantCalls = 0 + } + if len(config.calls) != wantCalls { + t.Fatalf("provider calls=%d, want %d", len(config.calls), wantCalls) + } + if wantCalls == 1 { + call := config.calls[0] + if call.ResourceID != resource.ID || call.ProviderUID != appContainerProviderID(resource) || call.Host != canonicalAppContainerHost(resource) || call.Platform != "truenas" { + t.Fatalf("request used session identity instead of canonical inventory: %+v", call) + } + } + if tc.session == "stale" { + cached, ok := executor.resolvedContext.GetResolvedResourceByID(resource.ID) + if !ok || strings.Join(cached.GetAllowedActions(), ",") != "query" { + t.Fatal("read expanded existing session action authority") + } + } + }) + } +} + func TestExecuteGetResourceConfig_TrueNASAppUsesNativeConfigProvider(t *testing.T) { provider := newTrueNASUnifiedQueryProvider(t) resolved := &mockResolvedContext{ From 4d302109cee0758a132ff150935630b50114cc05 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:38:54 +0100 Subject: [PATCH 4/9] Record storage diagnosis qualification limits Bind the ordinary read-only storage assessment to the corrected runtime and persisted tool evidence. Keep useful capacity observations separate from unsupported causal and temporal claims so completion is not counted as a qualified diagnosis or autonomous outcome. --- .../PATROL_ASSISTANT_CUSTOMER_JOURNEY.md | 60 +++++++++++++++++++ docs/release-control/v6/internal/status.json | 2 +- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md index 49e1259cc..76d5cb95b 100644 --- a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md +++ b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md @@ -1559,3 +1559,63 @@ Real-model retest, temporal/storage interpretation, approved and rejected action outcomes and independent recovery proof remain open. Autonomous subscription refusal and separately billed provider approval boundaries remain unchanged. + + +### Corrected storage evidence, ordinary Assistant retest + +The Docker observation and canonical configuration corrections are pushed to +PR #1935 at `355ac1f0a481d6dbc9a7ff3977bced0956711979`. Their exact staged +worker pre-commit checks passed without source changes. Remote checks remain +pending. The current configuration runtime also passed all fourteen contract +cases, the complete tools package and focused race checks. + +One ordinary read-only storage assessment ran on 2026-09-06 from +12:27:05.857Z to 12:30:03.726Z, an HTTP window of 177.869s. It used the existing +`claude-subscription:claude-opus-5` route, explicit `autonomous_mode=false`, +read-only control and thirteen successful tool reads. No infrastructure change, +paid-model request or autonomous readiness retry occurred. This is a single +assessment, not a success-rate or latency estimate. + +The answer identified the backup datastore at 90.6% utilisation and its active +capacity warning affecting seven workloads. It used the corrected container I/O +history, separated cumulative device counters from rates, acknowledged missing +container filesystem usage, and retained the reason for missing older history +as unknown. It did not attribute older host I/O peaks to the container whose +returned I/O window starts later. These are useful observations. + +The complete diagnosis still does not qualify. Its opening assurance that the +container is not short of space contradicts the later acknowledgement that +container filesystem usage is unavailable. It treats high retained host rates +as bucket/counter artifacts without establishing that mechanism. It includes a +host CPU maximum timestamped 21:00 the previous evening in a 03:00-04:00 window. +The suggested retention explanation is not established by capacity alone, and +available PBS job reads were not performed. Its rough growth extrapolation uses +retained extrema, not a measured first-to-last slope, and must retain that limit. +Corrected observations have not established reliable interpretation. + +The run also highlights a tool-context distinction to review: Docker-host +`agent_connected` describes the command connection, while telemetry may still +arrive through other collection paths. The model treated current telemetry and +that false connection flag as an unresolved inconsistency. Its storage-pools +request supplied `host`, although the tool schema only advertises that filter +for RAID and Ceph detail. That call returned all pools. Neither observation +justifies fabricating resource absence or collection downtime. + +Playwright exercised `/patrol` at 1440x1000 and 390x1000, the actual answer, +all thirteen expanded tool records, keyboard activation, deepest output +scrolling, Escape, reload and the persisted session. Every displayed input and +output matches the persisted tool records. Pixel review includes the answer, +evidence and the mobile table scrolled to its rightmost state. The table's +400-pixel content is reachable inside its 309-pixel horizontal viewport. +The artificial selected-route warning comes from blocked non-GET route checks, +so this does not qualify the unmodified provider-readiness UI. + +The runtime binary SHA256 stayed +`552699cdf2e61a4ca1cea2ac5ef4e065735cbd1dbca01665456e184bd4fc3533` +through the request and browser pass. Private request, source, binary, tool, +persistence, evaluation and pixel receipts are under workspace-relative +`tmp/patrol-storage-assistant-check/`. No native config action was requested, +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. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 21f5279cf..8e7091fb0 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -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. Exact regression, browser and delivery state is recorded in the plan. 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. 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.", "owner": "project-owner", "status": "planned", "recorded_at": "2026-09-05", From f5f440dbad18d83557104d2cf6197d8319949e44 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:10:11 +0100 Subject: [PATCH 5/9] 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. --- .../PATROL_ASSISTANT_CUSTOMER_JOURNEY.md | 74 +++++++++ docs/release-control/v6/internal/status.json | 2 +- .../v6/internal/subsystems/ai-runtime.md | 16 ++ frontend-modern/browser-verification.json | 22 +-- internal/ai/chat/service.go | 68 ++++---- .../chat/service_command_connection_test.go | 35 +++++ .../tools/command_connection_evidence_test.go | 145 ++++++++++++++++++ internal/ai/tools/data_types.go | 126 +++++++-------- internal/ai/tools/tools_query.go | 130 +++++++++------- 9 files changed, 455 insertions(+), 163 deletions(-) create mode 100644 internal/ai/chat/service_command_connection_test.go create mode 100644 internal/ai/tools/command_connection_evidence_test.go diff --git a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md index 76d5cb95b..198b1d391 100644 --- a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md +++ b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md @@ -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. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 8e7091fb0..fbb10c064 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -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", diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 3e851d1da..f7778928c 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 4e9b5706c..cc0bbd649 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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." ] } diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index 8309be743..c800e6bae 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -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 { diff --git a/internal/ai/chat/service_command_connection_test.go b/internal/ai/chat/service_command_connection_test.go new file mode 100644 index 000000000..b877879db --- /dev/null +++ b/internal/ai/chat/service_command_connection_test.go @@ -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) + } +} diff --git a/internal/ai/tools/command_connection_evidence_test.go b/internal/ai/tools/command_connection_evidence_test.go new file mode 100644 index 000000000..de9e2fcc0 --- /dev/null +++ b/internal/ai/tools/command_connection_evidence_test.go @@ -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: ®istryUnifiedQueryProvider{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"]) + } + } + } +} diff --git a/internal/ai/tools/data_types.go b/internal/ai/tools/data_types.go index 24b3f76c7..28062a5ef 100644 --- a/internal/ai/tools/data_types.go +++ b/internal/ai/tools/data_types.go @@ -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 diff --git a/internal/ai/tools/tools_query.go b/internal/ai/tools/tools_query.go index 9b49f6a6f..65551b77c 100644 --- a/internal/ai/tools/tools_query.go +++ b/internal/ai/tools/tools_query.go @@ -2151,7 +2151,7 @@ func (e *PulseToolExecutor) registerQueryTools() { e.registry.registerBuiltin(RegisteredTool{ Definition: Tool{ Name: agentcapabilities.PulseQueryToolName, - Description: `Query and search canonical infrastructure resources. Start here to discover systems, workloads, storage, and disks by name. Actions: search, get, config, topology, list, health. 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()]), }) } } From 6e18777d30f30b498def39d30016a697cabc4ea7 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:50:11 +0100 Subject: [PATCH 6/9] Preserve tmpfs mount evidence through collection and queries Docker can report tmpfs mounts only in HostConfig.Tmpfs. Preserve those entries in shared inventory and retain type, options and canonical write access in diagnostic queries. Configured size is not measured free space. Record the failed ordinary storage diagnosis and independently verified recovery without claiming autonomous or installed-collector qualification. --- .../PATROL_ASSISTANT_CUSTOMER_JOURNEY.md | 123 ++++++++++++++++++ docs/release-control/v6/internal/status.json | 2 +- .../v6/internal/subsystems/agent-lifecycle.md | 6 + .../v6/internal/subsystems/ai-runtime.md | 11 ++ .../v6/internal/subsystems/monitoring.md | 8 ++ .../v6/internal/subsystems/registry.json | 1 + frontend-modern/browser-verification.json | 45 +++++-- internal/ai/tools/data_types.go | 2 + .../ai/tools/tmpfs_mount_evidence_test.go | 87 +++++++++++++ internal/ai/tools/tools_query.go | 6 +- internal/dockeragent/collect.go | 31 +++++ internal/dockeragent/collect_tmpfs_test.go | 84 ++++++++++++ .../canonical_completion_guard_test.py | 1 + 13 files changed, 392 insertions(+), 15 deletions(-) create mode 100644 internal/ai/tools/tmpfs_mount_evidence_test.go create mode 100644 internal/dockeragent/collect_tmpfs_test.go diff --git a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md index 198b1d391..80de650f9 100644 --- a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md +++ b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md @@ -1693,3 +1693,126 @@ 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. + + +### Ordinary Assistant storage fault and recovery, 2026-09-06 + +The command-connection correction passed the exact nine-file worker pre-commit +and was pushed as `f5f440dbad18d83557104d2cf6197d8319949e44` in PR #1935. +Required CI remains in progress. This is not a release or a completed goal. + +An owned DockerLab run used the checked-in storage-pressure manifest on Tower. +Independent observations established a healthy worker with 8,347,648 free bytes, +then a real ENOSPC fault with zero free bytes, a running/unhealthy worker and a +healthy control. Pulse collection converged to both states before the request. +The ordinary read-only Assistant used the configured subscription Opus 5 route. +It was not an autonomous Patrol request and did not retry the cached refusal. + +The diagnosis took 114.847 seconds and ten tool calls. It identified the worker's +unhealthy state, the control's current healthy state and a failed command-route +log read. It did not retry that unavailable capability. However, it falsely +ruled out resource pressure using low CPU, memory, network and disk-read values. +Filesystem capacity was absent from its evidence and was independently full. +This is a failed diagnosis, despite its otherwise useful uncertainty statement +and suggested diagnostic read. No model-directed mutation occurred. + +After the answer, the independent oracle still found zero available bytes and +an unhealthy worker. Removing only the owned fill file restored 8,220,672 bytes +and healthy status. Pulse collected recovery and resolved the health alert. +A follow-up in the same Assistant session took 91.358 seconds and five new reads. +It correctly identified current recovery and the resolved alert, distinguished +symptom recovery from an unknown cause and did not invent an intervention. +It overstated continuous control health and non-impact from sparse observations. +The recovery assessment is partial, not a complete incident explanation. +An independent post-answer check confirmed healthy worker/control, no container restart +and 7,639,040 free bytes. Both cleanup passes passed, with no second-pass work +and unchanged unrelated inventory. The disposable resources are removed. + +Playwright exercised `/patrol` and Assistant at 1440x1000 and 390x1000, all fifteen +retained tool input/output pairs, keyboard expansion/collapse, deepest output +scrolling, complete answers, Escape, reload and the same retained conversation. +Rendered inputs/outputs match persisted records. Pixel inspection covered both +answers and the failed-access result on desktop and mobile. Runtime and source +hashes remained unchanged across both requests, with binary +`bcaf748107211ee733a6dc0f4d17220d9b4d1ce1918c25bde27cf3d10c0d6379`. +The route warning was an artifact of blocking non-chat POSTs in the proof browser. +The original autonomous refusal timestamp remained `2026-09-05T19:46:39Z`. +Private fixtures, source bindings, observations, screenshots and assessments +are at workspace-relative `tmp/patrol-storage-fault-case/`. + +### Next canonical correction: tmpfs inventory + +Before implementation, source and native inspection establish a collection gap: +Docker reports the owned scratch mount in `HostConfig.Tmpfs`, while `Mounts` is +empty. `internal/dockeragent/collect.go` copies only `Mounts`, so shared resource +queries falsely present an empty mount inventory. Preserve these native tmpfs +entries through the existing report mount type. Keep destination, type and +reported options, derive read/write from those options, preserve authoritative +existing mount records and deterministic ordering. Do not infer used/free space +from a configured size. No enrollment, permission or production agent change is +part of this collection correction. + +Proof plan: reproduce the captured tmpfs-only inspect shape through the actual +collector, then cover existing mounts, overlapping representations, read-only +options and absent host configuration. Run targeted/full collector checks on +pulse-dev and verify the report through the existing shared projection. Browser +proof after the final change must exercise mount evidence in resource details +and Assistant tool results, desktop and mobile, including deepest expansion and +reload. A captured-result rendering check is not installed-agent or model +qualification. Leave those limits explicit until the new collector is exercised +through a supported installed path. + +The incident lookup also needs an identity audit: the canonical container ID +returned no incident recording while the observed health alert used its legacy +Docker resource ID. This is a concrete lookup discrepancy to investigate, not +yet proof that a recording exists. Model inference from unmeasured capacity and +sparse health history remains an open quality failure. Supported autonomous +provider, approved/rejected actions and independent environments remain open. + +Further shared-projection inspection before editing found that `MountInfo` drops +native mount type/options and that canonical app-container queries derive write +access from equality with the single string `ro`, misreporting compound read-only +options. The same slice must preserve type/options and the canonical `RW` boolean +through both canonical-provider and typed read-state query paths. Add a query +regression and capture its actual output for final Assistant browser proof. +This remains mount configuration evidence, not measured filesystem capacity. + + +### Tmpfs collection and query contract proof + +The captured tmpfs-only and mixed-mount regressions failed against the previous +collector, then passed after the collection correction. The full dockeragent +package passed in 18.883s and focused race proof in 1.030s. Existing monitor report +mount propagation and discovery mount regressions passed. Both query paths +failed because type/options were lost, then passed after projection correction. +The full tools package passed in 59.473s and focused race proof in 1.030s. +A final output-only capture rerun passed in 0.013s. All proof used Go1.26.8 and +GOMAXPROCS4 on pulse-dev. The final Pro cross-build passed and the installed +local binary SHA256 is +`0a21dca4106c2ddc6873a3aca3b23378dccef35383ca00d7e9292b966de7c200`. +The managed local backend restarted and `/api/health` reported healthy. +No production collector was replaced. + +Final Playwright proof used captured canonical resources at `/docker`, widths +1920, 1440, 900 and 390 with height 1080. It exercised mount summary/title, +keyboard row expansion/collapse, mobile row tapping, adjacent detail state, +mount-destination search, Escape and reloaded search state. The existing wide +mount column is truncated with a full title. Responsive details have no dedicated +mount section. This is an existing presentation limitation, not full mobile +mount inspection qualification. Assistant's complete mount evidence is readable +at `/patrol`, 1440x1000, 900x1000 and 390x1000. Both actual query projections +passed exact input/output comparison, hover/focus, keyboard expansion/collapse, +deepest scrolling, controlled session reload and reopening retained records. +Pixels were inspected on desktop and mobile. Source/binary hashes stayed fixed. +The original autonomous refusal timestamp is unchanged. + +These browser fixtures qualify rendering of the corrected shared fields. They +do not qualify an installed collector, actual model interpretation of tmpfs +configuration, or durable backend persistence of those fixture sessions. The +ordinary live diagnosis/recovery records above have real server persistence +and retain their failed/partial judgments. Exact scoped hook and landing remain +required. Required model/action qualification and independent environments are +still open. The typed compatibility get path also does not accept the canonical +ID returned by its list path, so its mount regression uses an existing accepted +name. That identity residual is recorded for modernization, not silently fixed +through this mount projection. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index fbb10c064..4f9e7377a 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -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. 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.", + "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. The connection correction passed its exact worker hook and is pushed as f5f440dbad18d83557104d2cf6197d8319949e44 in PR1935, with CI ongoing. 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. A new ordinary storage-fault diagnosis took 114.847s and ten calls, then same-session recovery took 91.358s and five calls. Independent baseline, ENOSPC, unchanged fault after answer, recovery, post-answer health and two-pass cleanup all passed. Diagnosis failed by ruling out resource pressure without filesystem capacity evidence. Recovery identified current health and alert resolution but overstated continuous control health from sparse observations. Fifteen rendered tool records match persisted history. The shared collector currently being corrected drops HostConfig.Tmpfs, while query projection loses mount type/options and misreads compound read-only options. The collector and shared query corrections pass focused/full package and race proof, and final source-bound Docker/Assistant captured-result browser checks. Exact scoped hook and landing remain pending. Installed-collector and model qualification remain unperformed, and the existing responsive Docker details omit a dedicated mounts section. No autonomous call, action execution, provider-policy bypass or paid request was part of the case. Incident-record identity lookup and typed compatibility canonical-ID lookup remain documented follow-ups.", "owner": "project-owner", "status": "planned", "recorded_at": "2026-09-05", diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 29d4b8f97..b99bc49c6 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -15,6 +15,12 @@ ## Purpose +Docker mount reports include tmpfs configuration from `HostConfig.Tmpfs` +through the existing optional mount array. This adds collection evidence only. +It does not change admission, enrollment, execution permissions or agent +lifecycle authority. Existing agents continue to report their existing mount +coverage. Deploying an updated collector is a separate installed-path proof. + Docker block-I/O report presence fields are optional measurement metadata. They preserve zero and omitted directions independently without changing report admission, enrollment, identity, command permission or agent lifecycle state. diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index f7778928c..ed6cdf198 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -25,6 +25,17 @@ that same result. Successful reads retain their content and execution provenance ## Purpose +Shared app-container query mount evidence preserves native type, source, +destination, options and canonical read/write access. Compound options such as +`ro,noexec` cannot become writable through string equality heuristics. Both the +canonical provider and typed read-state projection preserve the same fields. +Configured size in mount options does not establish filesystem usage or free +space. `TestQueryPreservesMountConfigurationEvidence` covers these contracts. +The ordinary live storage case still failed diagnosis by excluding resource +pressure without capacity evidence. Mount fidelity alone does not qualify model +interpretation. Incident-record lookup across canonical and legacy Docker IDs +and typed compatibility lookup of canonical IDs remain explicit identity gaps. + 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 diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index e9c89ae80..2533243f0 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -17,6 +17,14 @@ ## Purpose +Docker mount collection preserves both native `Mounts` records and entries +reported only in `HostConfig.Tmpfs`. Existing reported destinations remain +authoritative. Additional tmpfs destinations are ordered deterministically, +retain their options and read/write setting, and use the existing mount report +shape. Configured tmpfs size is configuration, not measured used/free space. +`TestCollectContainerPreservesTmpfsMounts` reproduces a live tmpfs-only inspect +shape and covers mixed mounts, read-only options, overlap and absent host config. + Docker collection records read and write counter presence independently, including explicit zero, in optional report fields. Older reports without those fields establish only positive counters. Container reports propagate this diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 31e433330..0d47d4b63 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -6069,6 +6069,7 @@ "internal/dockeragent/agent_cpu_test.go", "internal/dockeragent/agent_internal_test.go", "internal/dockeragent/blockio_presence_test.go", + "internal/dockeragent/collect_tmpfs_test.go", "internal/dockeragent/swarm_coverage_test.go" ] }, diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index cc0bbd649..40505e579 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,25 +1,44 @@ { "version": 1, - "base_sha": "4d302109cee0758a132ff150935630b50114cc05", - "verified_at": "2026-09-06T12:57:13.096Z", + "base_sha": "f5f440dbad18d83557104d2cf6197d8319949e44", + "verified_at": "2026-09-06T13:39:36.848659+00:00", "result": "passed", "changed_paths": [], "content_sha256": {}, "backend_content_sha256": { - "internal/ai/tools/data_types.go": "00200b1366ee4bf1e541e3acf6618f56993b05b05fa08122b91cd2db320969ec", - "internal/ai/tools/tools_query.go": "69bd79091bc8892043b900ca8475d1ee9d25c9613cf0ca86e36e41a0e468e2f8", - "internal/ai/chat/service.go": "7b4a7696eb0a3ca2a99427d98f991740ff362bfc0c738c36f45f80549d7c2744" + "internal/dockeragent/collect.go": "869eb906ac8d6fe57304a296bc81bc689f72a07b5a76267982b81228d147852d", + "internal/ai/tools/data_types.go": "91fff7a17735c9f9f4bf64c7fe42c7d4c5cf9e436af36ac07dec92feb952afc6", + "internal/ai/tools/tools_query.go": "5ba1f2c8ec7aadb21d594c967a5b2858db3471b02174889b7f78472e3a33235b" }, - "binary_sha256": "bcaf748107211ee733a6dc0f4d17220d9b4d1ce1918c25bde27cf3d10c0d6379", + "binary_sha256": "0a21dca4106c2ddc6873a3aca3b23378dccef35383ca00d7e9292b966de7c200", "rendering_content_sha256": { "frontend-modern/src/components/AI/Chat/hooks/useChat.ts": "0b56b7a56e35d51ca96f0e126dd493b3164aa9e0ad4d8ae24bcf3af7a574b97c", "frontend-modern/src/components/AI/Chat/ChatMessages.tsx": "9672f7608d1e3a531c73cba20fd4a78752316783212afd0c292ddfd11d2bf371", - "frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx": "cc7bd548a418c3863486f0fe987c5c3110c2f6cdfa70b630b2ec05fb794d60d6" + "frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx": "cc7bd548a418c3863486f0fe987c5c3110c2f6cdfa70b630b2ec05fb794d60d6", + "frontend-modern/src/features/docker/DockerContainersTable.tsx": "af58fb901b55b188be084105e78c71084af5a594d1a7dde2647959eb00cc6131", + "frontend-modern/src/features/docker/dockerContainerTableModel.ts": "48bf66fe7e3bacf6352c48d7ccd568dd337e9d27670da435f30c3caabccf79bc" }, "routes": [ + "/docker", "/patrol" ], "viewports": [ + { + "width": 1920, + "height": 1080 + }, + { + "width": 1440, + "height": 1080 + }, + { + "width": 900, + "height": 1080 + }, + { + "width": 390, + "height": 1080 + }, { "width": 1440, "height": 1000 @@ -34,13 +53,13 @@ } ], "states": [ - "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." + "Captured canonical app-container and parent agent with tmpfs and bind mounts. Existing mount summary is a wide-table column, with no dedicated mount section in responsive detail views.", + "Two captured shared query results preserve native type, options, source, destination and RW. They establish mount configuration, not measured capacity.", + "Final Pro source/binary binding and original cached refusal preserved. No provider or production mutation in rendering proof." ], "interactions": [ - "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." + "Docker mount summary/title hover, keyboard expansion/collapse, mobile row tap, adjacent detail state, mount destination search, Escape and reloaded search.", + "Assistant exact input/output comparison, hover/focus, Enter/Space, deepest output scrolling, Escape, full reload and controlled session selection/reopening.", + "Pixel inspection at desktop/mobile. Private receipts: tmp/patrol-storage-fault-case/browser and docker-browser. Captured REST/SSE fixtures do not qualify installed collector, model diagnosis or server persistence." ] } diff --git a/internal/ai/tools/data_types.go b/internal/ai/tools/data_types.go index 28062a5ef..8344ec73d 100644 --- a/internal/ai/tools/data_types.go +++ b/internal/ai/tools/data_types.go @@ -883,8 +883,10 @@ type PortInfo struct { // MountInfo describes a volume mount type MountInfo struct { + Type string `json:"type,omitempty"` Source string `json:"source"` Destination string `json:"destination"` + Mode string `json:"mode,omitempty"` ReadWrite bool `json:"rw"` } diff --git a/internal/ai/tools/tmpfs_mount_evidence_test.go b/internal/ai/tools/tmpfs_mount_evidence_test.go new file mode 100644 index 000000000..34615d657 --- /dev/null +++ b/internal/ai/tools/tmpfs_mount_evidence_test.go @@ -0,0 +1,87 @@ +package tools + +import ( + "context" + "encoding/json" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +func TestQueryPreservesMountConfigurationEvidence(t *testing.T) { + for _, provider := range []bool{false, true} { + name := "typed read state" + if provider { + name = "canonical provider" + } + t.Run(name, func(t *testing.T) { + snapshot := commandEvidenceSnapshot() + snapshot.DockerHosts[0].Containers[0].Mounts = []models.DockerContainerMount{ + {Type: "tmpfs", Destination: "/var/lib/service-cache", Mode: "rw,noexec,nosuid,nodev,size=8388608", RW: true}, + {Type: "tmpfs", Destination: "/readonly-cache", Mode: "ro,noexec", RW: false}, + {Type: "bind", Source: "/host/data", Destination: "/data", Mode: "", RW: false}, + } + registry := unifiedresources.NewRegistry(nil) + registry.IngestSnapshot(snapshot) + cfg := ExecutorConfig{ReadState: registry, ControlLevel: ControlLevelReadOnly} + if provider { + cfg.UnifiedResourceProvider = ®istryUnifiedQueryProvider{registry} + } + executor := NewPulseToolExecutor(cfg) + list, err := executor.executeQuery(context.Background(), map[string]interface{}{"action": "list", "type": "docker-hosts"}) + if err != nil || list.IsError { + t.Fatalf("list: %v %+v", err, list) + } + var hosts map[string]any + if err := json.Unmarshal([]byte(list.Content[0].Text), &hosts); err != nil { + t.Fatal(err) + } + id := hosts["docker_hosts"].([]any)[0].(map[string]any)["containers"].([]any)[0].(map[string]any)["id"] + if !provider { + // The typed compatibility path currently accepts provider IDs/names. + id = snapshot.DockerHosts[0].Containers[0].Name + } + args := map[string]interface{}{"action": "get", "resource_type": "app-container", "resource_id": id} + result, err := executor.executeQuery(context.Background(), args) + if err != nil || result.IsError { + t.Fatalf("get: %v %+v", err, result) + } + var decoded map[string]any + if err := json.Unmarshal([]byte(result.Content[0].Text), &decoded); err != nil { + t.Fatal(err) + } + mounts, ok := decoded["mounts"].([]any) + if !ok { + t.Fatalf("missing mount projection: %+v", decoded) + } + if len(mounts) != 3 { + t.Fatalf("lost mounts: %+v", mounts) + } + for i, want := range snapshot.DockerHosts[0].Containers[0].Mounts { + got := mounts[i].(map[string]any) + if got["type"] != want.Type || got["source"] != want.Source || got["destination"] != want.Destination || got["rw"] != want.RW || (want.Mode != "" && got["mode"] != want.Mode) { + t.Fatalf("mount provenance/access changed: %+v, want %+v", got, want) + } + } + if _, exists := decoded["disk"]; exists { + t.Fatalf("mount configuration invented capacity: %+v", decoded["disk"]) + } + capture, _ := json.Marshal(map[string]any{"case": name, "input": args, "output": decoded}) + t.Logf("MOUNT_EVIDENCE %s", capture) + if provider { + resources := registry.ListByType(unifiedresources.ResourceTypeAppContainer) + for _, host := range registry.ListByType(unifiedresources.ResourceTypeAgent) { + if host.Docker != nil { + resources = append(resources, host) + } + } + encoded, err := json.Marshal(resources) + if err != nil { + t.Fatal(err) + } + t.Logf("MOUNT_RESOURCES %s", encoded) + } + }) + } +} diff --git a/internal/ai/tools/tools_query.go b/internal/ai/tools/tools_query.go index 65551b77c..53e63b504 100644 --- a/internal/ai/tools/tools_query.go +++ b/internal/ai/tools/tools_query.go @@ -5057,9 +5057,11 @@ func (e *PulseToolExecutor) executeGetResource(_ context.Context, args map[strin } for _, m := range resource.Docker.Mounts { response.Mounts = append(response.Mounts, MountInfo{ + Type: m.Type, Source: m.Source, Destination: m.Destination, - ReadWrite: !strings.EqualFold(strings.TrimSpace(m.Mode), "ro"), + Mode: m.Mode, + ReadWrite: m.RW, }) } } @@ -5164,8 +5166,10 @@ func (e *PulseToolExecutor) executeGetResource(_ context.Context, args map[strin for _, m := range container.Mounts() { response.Mounts = append(response.Mounts, MountInfo{ + Type: m.Type, Source: m.Source, Destination: m.Destination, + Mode: m.Mode, ReadWrite: m.RW, }) } diff --git a/internal/dockeragent/collect.go b/internal/dockeragent/collect.go index ebb8d0dfa..02223dc3a 100644 --- a/internal/dockeragent/collect.go +++ b/internal/dockeragent/collect.go @@ -10,6 +10,7 @@ import ( "net/netip" "net/url" "regexp" + "sort" "strconv" "strings" "time" @@ -698,6 +699,36 @@ func (a *Agent) collectContainer(ctx context.Context, summary containertypes.Sum }) } } + // Docker's --tmpfs mounts can exist only in HostConfig.Tmpfs. Preserve + // their configuration alongside inspected mounts without inventing usage. + if inspect.HostConfig != nil && len(inspect.HostConfig.Tmpfs) > 0 { + reported := make(map[string]bool, len(mounts)) + for _, mount := range mounts { + reported[mount.Destination] = true + } + destinations := make([]string, 0, len(inspect.HostConfig.Tmpfs)) + for destination := range inspect.HostConfig.Tmpfs { + if !reported[destination] { + destinations = append(destinations, destination) + } + } + sort.Strings(destinations) + for _, destination := range destinations { + options := inspect.HostConfig.Tmpfs[destination] + writable := true + for _, option := range strings.Split(options, ",") { + switch strings.TrimSpace(option) { + case "ro": + writable = false + case "rw": + writable = true + } + } + mounts = append(mounts, agentsdocker.ContainerMount{ + Type: "tmpfs", Destination: destination, Mode: options, RW: writable, + }) + } + } oomKilled := inspect.State.OOMKilled container := agentsdocker.Container{ diff --git a/internal/dockeragent/collect_tmpfs_test.go b/internal/dockeragent/collect_tmpfs_test.go new file mode 100644 index 000000000..c9b6340ad --- /dev/null +++ b/internal/dockeragent/collect_tmpfs_test.go @@ -0,0 +1,84 @@ +package dockeragent + +import ( + "context" + "encoding/json" + "reflect" + "testing" + + containertypes "github.com/moby/moby/api/types/container" + agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" + "github.com/rs/zerolog" +) + +func TestCollectContainerPreservesTmpfsMounts(t *testing.T) { + for _, tc := range []struct { + name string + host *containertypes.HostConfig + mounts []containertypes.MountPoint + want []agentsdocker.ContainerMount + }{ + { + name: "native tmpfs only", + host: &containertypes.HostConfig{Tmpfs: map[string]string{ + "/var/lib/service-cache": "rw,noexec,nosuid,nodev,size=8388608", + }}, + want: []agentsdocker.ContainerMount{{Type: "tmpfs", Destination: "/var/lib/service-cache", Mode: "rw,noexec,nosuid,nodev,size=8388608", RW: true}}, + }, + { + name: "mixed mounts and stable tmpfs order", + host: &containertypes.HostConfig{Tmpfs: map[string]string{"/z-cache": "", "/a-cache": "ro,noexec"}}, + mounts: []containertypes.MountPoint{{Type: "bind", Source: "/host-data", Destination: "/data", RW: true}}, + want: []agentsdocker.ContainerMount{ + {Type: "bind", Source: "/host-data", Destination: "/data", RW: true}, + {Type: "tmpfs", Destination: "/a-cache", Mode: "ro,noexec", RW: false}, + {Type: "tmpfs", Destination: "/z-cache", RW: true}, + }, + }, + { + name: "reported mount is authoritative", + host: &containertypes.HostConfig{Tmpfs: map[string]string{"/cache": "rw,size=8388608"}}, + mounts: []containertypes.MountPoint{{Type: "tmpfs", Destination: "/cache", Mode: "ro", RW: false}}, + want: []agentsdocker.ContainerMount{{Type: "tmpfs", Destination: "/cache", Mode: "ro", RW: false}}, + }, + {name: "absent host config"}, + } { + t.Run(tc.name, func(t *testing.T) { + inspect := baseInspect() + inspect.HostConfig = tc.host + inspect.Mounts = tc.mounts + a := &Agent{ + logger: zerolog.Nop(), + runtime: RuntimeDocker, + prevContainerCPU: make(map[string]cpuSample), + docker: &fakeDockerClient{ + containerInspectWithRawFn: func(context.Context, string, bool) (containertypes.InspectResponse, []byte, error) { + return inspect, nil, nil + }, + containerStatsOneShotFn: func(context.Context, string) (dockerStatsResponseReader, error) { + return statsReader(t, containertypes.StatsResponse{}), nil + }, + }, + } + got, err := a.collectContainer(context.Background(), containertypes.Summary{ID: "owned-storage-container", Names: []string{"/worker"}, Image: "alpine:3.20", State: "running"}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got.Mounts, tc.want) { + t.Fatalf("mount inventory = %#v, want %#v", got.Mounts, tc.want) + } + encoded, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + var wire agentsdocker.Container + if err := json.Unmarshal(encoded, &wire); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(wire.Mounts, tc.want) { + t.Fatalf("report lost mounts: %#v", wire.Mounts) + } + t.Logf("TMPFS_COLLECTOR_REPORT %s", encoded) + }) + } +} diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 0697340f4..19a8e0c96 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -356,6 +356,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "internal/dockeragent/agent_cpu_test.go", "internal/dockeragent/agent_internal_test.go", "internal/dockeragent/blockio_presence_test.go", + "internal/dockeragent/collect_tmpfs_test.go", "internal/dockeragent/swarm_coverage_test.go", ], } From 580a246981c76b401e9007f5ac65c89355b64c6d Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:33:44 +0100 Subject: [PATCH 7/9] Read incident evidence from canonical resource history Assistant incident reads used an unconnected cached-metric recorder while resource history already retained operational events. Read the shared organization-scoped timeline with original provenance, bounded results and explicit coverage and failure semantics. Keep legacy archive reads resource-bound. Record the separately reproduced alert identity split at the shared write boundary. This read-path correction does not qualify complete incident diagnosis or recovery. --- .../PATROL_ASSISTANT_CUSTOMER_JOURNEY.md | 70 +++++++++ docs/release-control/v6/internal/status.json | 2 +- .../v6/internal/subsystems/ai-runtime.md | 16 +++ frontend-modern/browser-verification.json | 44 ++---- internal/ai/tools/executor.go | 2 +- internal/ai/tools/incident_history_test.go | 134 ++++++++++++++++++ internal/ai/tools/tools_knowledge.go | 85 ++++++++--- 7 files changed, 299 insertions(+), 54 deletions(-) create mode 100644 internal/ai/tools/incident_history_test.go diff --git a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md index 80de650f9..d7b545793 100644 --- a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md +++ b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md @@ -1816,3 +1816,73 @@ still open. The typed compatibility get path also does not accept the canonical ID returned by its list path, so its mount regression uses an existing accepted name. That identity residual is recorded for modernization, not silently fixed through this mount projection. + +## Canonical incident history, 2026-09-06 + +The tmpfs correction passed the exact worker hook and was pushed as +`6e18777d30f30b498def39d30016a697cabc4ea7` in PR #1935. That scoped +delivery does not change the failed storage diagnosis or partial recovery verdict. + +The incident audit found a source-of-truth mismatch, not evidence that an existing +recording merely needed an ID alias. The legacy five-second recorder has no +production alert callback connected to its coordinator. It samples cached values +using recorder time without preserving their source measurement time. Connecting +that recorder would not supply trustworthy higher-frequency history. + +The canonical resource timeline already stores observed changes, alert lifecycle +events and executed actions. Assistant handoffs use a bounded excerpt of this +same store. The shared `pulse_knowledge` incidents action now reads that +organization-pinned timeline directly, using the supplied canonical resource ID. +It does not require the resource still to exist in current inventory, infer +identity from names, include related resources implicitly, or reconstruct events +from current metrics. The response preserves canonical source, observation and +optional occurrence timestamps, state transitions and metadata. `since` filters +on observation time, and bounded results report `has_more`. Empty retained history +does not establish health. Missing or failed storage is a failed read. + +Explicit legacy `window_id` lookups remain isolated archive reads, must match the +requested resource, and explain that sample timestamps do not establish source +freshness. The primary incidents action no longer uses those recordings. The +legacy recorder/coordinator startup and API active-count plumbing still exist. +Their retirement is a separate cleanup in this redesign and must preserve any +saved archives. Do not connect them as a replacement incident truth source. + +Qualification uses the real SQLite resource store with a fired/resolved lifecycle, +an older excluded record, a related-resource negative control, absent occurrence +time, truncation and empty history. Unavailable/failed storage, invalid input and +archive resource isolation are separate negative controls. Captured actual tool +responses must pass the Assistant expansion, scrolling and reload matrix at +`/patrol`, 1440x1000, 900x1000 and 390x1000. This is contract and rendering proof, +not a new real-model or continuous-coverage claim. + +Read-only API inspection of the actual removed storage fixture confirmed a +remaining canonical write-boundary defect. The canonical app-container timeline +returns its creation and removal, while the fired event at 13:14:28.59485Z and +resolved event at 13:17:58.634095Z remain under its legacy Docker resource ID. +The resource API includes related network changes by design. The new tool uses +direct resource history only. `recordAlertTimelineChange` passes the alert's +source ID directly to `BuildAlertTimelineChange`, and `MonitorAdapter.RecordChange` +forwards it without canonical resolution. Consequently this read-path change is +only partial incident-history remediation. The next required owning fix must +resolve event identity before persistence and preserve access to retained prior +identity records, including removed resources, through the shared identity/history +contract. It must not add a Docker string rewrite inside the Assistant tool. +The shared writer and retained-identity correction remain required in this goal. +Private raw API receipts are in `tmp/patrol-canonical-history/live-timeline.json` +and `live-legacy-alert-timeline.json`. No model call or infrastructure mutation +was made during these reads. + +The history regression passes through the registered tool dispatcher. The full +tools package passed in 59.856s, the final focused capture passed in 0.036s, and the +focused race check passed in 1.189s on pulse-dev with Go1.26.8 and GOMAXPROCS4. +The final Pro build passed and was installed into the local development stack. +Its SHA256 is `4929aeb869db54122bc5525352d3126c0e9fa7c4847e3fdfa741500600b5e00d`. +The managed backend restarted healthy. Final Playwright proof passed all five +registered-tool cases at `/patrol`, 1440x1000, 900x1000 and 390x1000, including +hover/focus, Enter/Space, deepest output scrolling, Escape and controlled session +reload with exact input/output and success/failure comparison. Root inspected +actual pixels at all three widths. Source and binary hashes remained fixed. +The provider warning stayed visible and no retry, route switch or provider +request was made. This is captured-response rendering, not real-model diagnosis +or server persistence qualification. The exact scoped worker hook gates delivery +through PR #1935. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 4f9e7377a..af9de87e9 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -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. 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. The connection correction passed its exact worker hook and is pushed as f5f440dbad18d83557104d2cf6197d8319949e44 in PR1935, with CI ongoing. 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. A new ordinary storage-fault diagnosis took 114.847s and ten calls, then same-session recovery took 91.358s and five calls. Independent baseline, ENOSPC, unchanged fault after answer, recovery, post-answer health and two-pass cleanup all passed. Diagnosis failed by ruling out resource pressure without filesystem capacity evidence. Recovery identified current health and alert resolution but overstated continuous control health from sparse observations. Fifteen rendered tool records match persisted history. The shared collector currently being corrected drops HostConfig.Tmpfs, while query projection loses mount type/options and misreads compound read-only options. The collector and shared query corrections pass focused/full package and race proof, and final source-bound Docker/Assistant captured-result browser checks. Exact scoped hook and landing remain pending. Installed-collector and model qualification remain unperformed, and the existing responsive Docker details omit a dedicated mounts section. No autonomous call, action execution, provider-policy bypass or paid request was part of the case. Incident-record identity lookup and typed compatibility canonical-ID lookup remain documented follow-ups.", + "summary": "The redesign goal remains open, with its executable plan and detailed receipts in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. Patrol owns investigation and Assistant continues the same issue. Observations, hypotheses, accepted proposals, execution and independently verified outcomes remain distinct. The recorded baseline contains 127 paid installations, 71 Patrol-enabled, 23 with Assistant calls and fourteen verified resolutions from one installation. Schema17 outcome/provider/cost fields had no adoption. These do not establish representative success, false-alarm or missed-problem rates. Shared provenance/history/risk fixes and removal of proposal-as-proof and proxy completion policy landed through PR1928/1929. PR1934 merged canonical tool/transcript identity. PR1935 contains measurement-presence, capacity, canonical config-read, command-connectivity and tmpfs collection/query corrections through 6e18777d30f30b498def39d30016a697cabc4ea7. Exact worker hooks and named browser matrices passed, with remote landing pending. Real ordinary storage diagnosis failed by ruling out pressure without filesystem capacity evidence. Same-session recovery correctly identified present health and alert resolution but overstated continuous control health. Independent fault-intact, recovery, two-pass cleanup and persisted transcript checks passed. The current incident history correction replaces the primary legacy recorder read with the existing organization-pinned canonical resource timeline, preserving source/time semantics, bounded history, empty coverage and failed-read distinctions. Explicit legacy archive reads remain resource-bound. Its registered-tool SQLite, full tools package, focused race and final-source browser proofs pass. The exact scoped worker hook gates delivery through PR1935. Live reads of the removed storage fixture confirm that health alert fired/resolved records remain under the legacy Docker ID while creation/removal use the canonical app-container ID. This is partial incident-history remediation. The shared timeline writer and retained identity/history contract must repair that split, including removed resources, without an Assistant-local ID rewrite. The unused legacy recorder/coordinator startup needs retirement while preserving saved archives. Installed tmpfs collector and real-model interpretation remain unqualified. Other named residuals include unsupported filters, typed compatibility canonical-ID lookup, legacy direction availability, Docker-host history and responsive mount details. Claude Max explicitly refused autonomous Patrol readiness. Cached refusal and API409 enforcement remain enforced. Separate paid-provider approval is pending, with no paid request or policy bypass. Ordinary Assistant is not autonomous qualification. Required local work still includes reliable interpretation, config-read/model retest, storage/backup and approved/rejected action outcomes. Independent volunteered Pro environments remain a separate wider-readiness gate.", "owner": "project-owner", "status": "planned", "recorded_at": "2026-09-05", diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index ed6cdf198..7e4b62e2b 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -3605,6 +3605,22 @@ has a single definition in the canonical resource contract. ## Completion Obligations +The `pulse_knowledge` incidents action reads the organization-pinned canonical +resource timeline used by resource history and Assistant handoffs. It preserves +resource identity, observation and optional occurrence time, source and event +metadata. Reads use explicit observation-time bounds and a bounded event count +with truncation disclosure. Empty retained history is not continuous healthy +coverage, and an unavailable or failed history store is a failed tool read. +Legacy recording IDs are isolated archive lookups bound to the requested +resource. Their recorder timestamps cannot establish source measurement time. +The primary history path must not restore the legacy recorder as a parallel +incident authority or derive fresh history by resampling cached metrics. +`TestIncidentHistoryRetainsCanonicalEvidence` uses SQLite lifecycle records, +time/resource negative controls, missing occurrence time and bounded reads. +The corresponding unavailable/invalid and archive tests cover failure semantics +and resource isolation. Live model interpretation remains governed by the +customer journey qualification plan. + Every per-organization Assistant or legacy AI service that can discover or dispatch through the host-agent command transport must receive an organization-pinned command-server view. A tenant service must never enumerate diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 40505e579..a87940a56 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,44 +1,24 @@ { "version": 1, - "base_sha": "f5f440dbad18d83557104d2cf6197d8319949e44", - "verified_at": "2026-09-06T13:39:36.848659+00:00", + "base_sha": "6e18777d30f30b498def39d30016a697cabc4ea7", + "verified_at": "2026-09-06T14:26:55.462Z", "result": "passed", "changed_paths": [], "content_sha256": {}, "backend_content_sha256": { - "internal/dockeragent/collect.go": "869eb906ac8d6fe57304a296bc81bc689f72a07b5a76267982b81228d147852d", - "internal/ai/tools/data_types.go": "91fff7a17735c9f9f4bf64c7fe42c7d4c5cf9e436af36ac07dec92feb952afc6", - "internal/ai/tools/tools_query.go": "5ba1f2c8ec7aadb21d594c967a5b2858db3471b02174889b7f78472e3a33235b" + "internal/ai/tools/tools_knowledge.go": "d98ea1bab481acce5eee4988d74d364dc73c4068ca33725d843b54117cc9cc85", + "internal/ai/tools/executor.go": "c476d6aec1f7925467f1547f0219e6c6265639a62b397649adc82a8ed87b7e4a" }, - "binary_sha256": "0a21dca4106c2ddc6873a3aca3b23378dccef35383ca00d7e9292b966de7c200", + "binary_sha256": "4929aeb869db54122bc5525352d3126c0e9fa7c4847e3fdfa741500600b5e00d", "rendering_content_sha256": { "frontend-modern/src/components/AI/Chat/hooks/useChat.ts": "0b56b7a56e35d51ca96f0e126dd493b3164aa9e0ad4d8ae24bcf3af7a574b97c", "frontend-modern/src/components/AI/Chat/ChatMessages.tsx": "9672f7608d1e3a531c73cba20fd4a78752316783212afd0c292ddfd11d2bf371", - "frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx": "cc7bd548a418c3863486f0fe987c5c3110c2f6cdfa70b630b2ec05fb794d60d6", - "frontend-modern/src/features/docker/DockerContainersTable.tsx": "af58fb901b55b188be084105e78c71084af5a594d1a7dde2647959eb00cc6131", - "frontend-modern/src/features/docker/dockerContainerTableModel.ts": "48bf66fe7e3bacf6352c48d7ccd568dd337e9d27670da435f30c3caabccf79bc" + "frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx": "cc7bd548a418c3863486f0fe987c5c3110c2f6cdfa70b630b2ec05fb794d60d6" }, "routes": [ - "/docker", "/patrol" ], "viewports": [ - { - "width": 1920, - "height": 1080 - }, - { - "width": 1440, - "height": 1080 - }, - { - "width": 900, - "height": 1080 - }, - { - "width": 390, - "height": 1080 - }, { "width": 1440, "height": 1000 @@ -53,13 +33,13 @@ } ], "states": [ - "Captured canonical app-container and parent agent with tmpfs and bind mounts. Existing mount summary is a wide-table column, with no dedicated mount section in responsive detail views.", - "Two captured shared query results preserve native type, options, source, destination and RW. They establish mount configuration, not measured capacity.", - "Final Pro source/binary binding and original cached refusal preserved. No provider or production mutation in rendering proof." + "Five captured registered-tool results: retained canonical lifecycle, bounded/truncated history, empty retained history, unavailable store, and failed store read.", + "Observation and optional occurrence timestamps, source metadata, empty arrays, coverage limitations and failed tool status survive rendering.", + "Existing provider route warning remains visible. No retry, route switch, model request or infrastructure mutation was performed." ], "interactions": [ - "Docker mount summary/title hover, keyboard expansion/collapse, mobile row tap, adjacent detail state, mount destination search, Escape and reloaded search.", - "Assistant exact input/output comparison, hover/focus, Enter/Space, deepest output scrolling, Escape, full reload and controlled session selection/reopening.", - "Pixel inspection at desktop/mobile. Private receipts: tmp/patrol-storage-fault-case/browser and docker-browser. Captured REST/SSE fixtures do not qualify installed collector, model diagnosis or server persistence." + "Hover/focus and Enter expansion, exact input/output comparison, deepest output scroll and pixel inspection at desktop/intermediate/mobile widths, Space collapse and Escape.", + "Full reload, controlled session selection, reopen each result and compare exact retained input/output and completed/failed state.", + "Final verified Pro build installed locally, healthy restart, fixed source/binary hashes before and after. Private receipt: tmp/patrol-canonical-history/browser/receipt.json. Captured response/session fixtures qualify rendering, not real-model diagnosis or backend persistence." ] } diff --git a/internal/ai/tools/executor.go b/internal/ai/tools/executor.go index 7b1870a17..bf045aca2 100644 --- a/internal/ai/tools/executor.go +++ b/internal/ai/tools/executor.go @@ -1221,7 +1221,7 @@ func (e *PulseToolExecutor) isToolAvailable(name string) bool { case agentcapabilities.PulseDiscoveryToolName: return e.discoveryProvider != nil case agentcapabilities.PulseKnowledgeToolName: - return e.knowledgeStoreProvider != nil || e.incidentRecorderProvider != nil || e.eventCorrelatorProvider != nil + return e.actionAuditStore != nil || e.knowledgeStoreProvider != nil || e.incidentRecorderProvider != nil || e.eventCorrelatorProvider != nil case agentcapabilities.PulsePMGToolName: return e.hasReadState() case agentcapabilities.PulseSummarizeToolName: diff --git a/internal/ai/tools/incident_history_test.go b/internal/ai/tools/incident_history_test.go new file mode 100644 index 000000000..08df16b58 --- /dev/null +++ b/internal/ai/tools/incident_history_test.go @@ -0,0 +1,134 @@ +package tools + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" + "github.com/stretchr/testify/require" +) + +type failedIncidentHistoryStore struct{ unifiedresources.ResourceStore } + +func (failedIncidentHistoryStore) GetRecentChanges(string, time.Time, int) ([]unifiedresources.ResourceChange, error) { + return nil, errors.New("history store unavailable") +} + +type incidentArchiveFixture struct{ window *IncidentWindow } + +func (s incidentArchiveFixture) GetWindow(string) *IncidentWindow { return s.window } +func (s incidentArchiveFixture) GetWindowsForResource(string, int) []*IncidentWindow { + panic("canonical incident reads must not enumerate legacy recordings") +} + +func TestIncidentHistoryRetainsCanonicalEvidence(t *testing.T) { + store, err := unifiedresources.NewSQLiteResourceStore(t.TempDir(), "incident-test") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + resourceID := "app-container-7020f37498275208" + start := time.Now().UTC().Add(-time.Hour).Truncate(time.Second) + occurred := start.Add(-time.Minute) + changes := []unifiedresources.ResourceChange{ + {ID: "outside-window", ResourceID: resourceID, ObservedAt: start.Add(-time.Second), Kind: unifiedresources.ChangeRestart, SourceType: unifiedresources.SourcePlatformEvent}, + {ID: "alert-fired", ResourceID: resourceID, ObservedAt: start.Add(time.Minute), OccurredAt: &occurred, Kind: unifiedresources.ChangeAlertFired, SourceType: unifiedresources.SourcePulseDiff, From: "healthy", To: "unhealthy", Reason: "Health check failed", Metadata: map[string]any{"alert_id": "health-check", "value": 1.0}}, + {ID: "related-only", ResourceID: "agent-parent", RelatedResources: []string{resourceID}, ObservedAt: start.Add(2 * time.Minute), Kind: unifiedresources.ChangeRestart, SourceType: unifiedresources.SourcePlatformEvent}, + {ID: "alert-resolved", ResourceID: resourceID, ObservedAt: start.Add(3 * time.Minute), Kind: unifiedresources.ChangeAlertResolved, SourceType: unifiedresources.SourcePulseDiff, From: "unhealthy", To: "healthy"}, + } + for _, change := range changes { + require.NoError(t, store.RecordChange(change)) + } + // No live inventory or incident recorder is necessary to read a retained + // event for a container that has since been removed. + exec := NewPulseToolExecutor(ExecutorConfig{ActionAuditStore: store, IncidentRecorderProvider: incidentArchiveFixture{}}) + require.True(t, exec.isToolAvailable(agentcapabilities.PulseKnowledgeToolName)) + for _, tc := range []struct { + name string + id string + limit int + wantCount int + wantMore bool + }{ + {"retained lifecycle", resourceID, 50, 2, false}, + {"bounded lifecycle", resourceID, 1, 1, true}, + {"empty history", "app-container-absent", 50, 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + input := map[string]interface{}{"action": "incidents", "resource_id": tc.id, "since": start.Format(time.RFC3339), "limit": float64(tc.limit)} + result, err := exec.registry.Execute(context.Background(), exec, agentcapabilities.PulseKnowledgeToolName, input) + require.NoError(t, err) + require.False(t, result.IsError, result.Content) + var got struct { + Source string `json:"source"` + Events []unifiedresources.ResourceChange `json:"events"` + HasMore bool `json:"has_more"` + Coverage string `json:"coverage"` + TimeBasis string `json:"time_basis"` + } + require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &got)) + require.Equal(t, "canonical_resource_timeline", got.Source) + require.Equal(t, "retained_records_only", got.Coverage) + require.Equal(t, "observed_at", got.TimeBasis) + require.NotNil(t, got.Events) + require.Len(t, got.Events, tc.wantCount) + require.Equal(t, tc.wantMore, got.HasMore) + if tc.wantCount > 0 { + require.Equal(t, "alert-resolved", got.Events[0].ID) + require.Nil(t, got.Events[0].OccurredAt) + } + if tc.wantCount == 2 { + require.Equal(t, changes[1], got.Events[1]) + } + capture, err := json.Marshal(map[string]any{"case": tc.name, "input": input, "result": result}) + require.NoError(t, err) + t.Logf("INCIDENT_EVIDENCE %s", capture) + }) + } +} + +func TestIncidentHistoryUnavailableAndInvalid(t *testing.T) { + for _, tc := range []struct { + name string + store unifiedresources.ResourceStore + input map[string]interface{} + }{ + {"unavailable", nil, map[string]interface{}{"resource_id": "app-container-1"}}, + {"failed", failedIncidentHistoryStore{}, map[string]interface{}{"resource_id": "app-container-1"}}, + {"empty resource", unifiedresources.NewMemoryStore(), map[string]interface{}{"resource_id": " "}}, + {"invalid time", unifiedresources.NewMemoryStore(), map[string]interface{}{"resource_id": "app-container-1", "since": "yesterday"}}, + {"future time", unifiedresources.NewMemoryStore(), map[string]interface{}{"resource_id": "app-container-1", "since": time.Now().Add(time.Hour).Format(time.RFC3339)}}, + {"negative limit", unifiedresources.NewMemoryStore(), map[string]interface{}{"resource_id": "app-container-1", "limit": float64(-1)}}, + {"large limit", unifiedresources.NewMemoryStore(), map[string]interface{}{"resource_id": "app-container-1", "limit": float64(201)}}, + } { + t.Run(tc.name, func(t *testing.T) { + exec := NewPulseToolExecutor(ExecutorConfig{ActionAuditStore: tc.store, IncidentRecorderProvider: incidentArchiveFixture{}}) + tc.input["action"] = "incidents" + result, err := exec.registry.Execute(context.Background(), exec, agentcapabilities.PulseKnowledgeToolName, tc.input) + require.NoError(t, err) + require.True(t, result.IsError, result.Content) + capture, err := json.Marshal(map[string]any{"case": tc.name, "input": tc.input, "result": result}) + require.NoError(t, err) + t.Logf("INCIDENT_EVIDENCE %s", capture) + }) + } +} + +func TestIncidentHistoryLegacyArchiveIsResourceBound(t *testing.T) { + window := &IncidentWindow{ID: "archive-1", ResourceID: "vm-1"} + exec := NewPulseToolExecutor(ExecutorConfig{IncidentRecorderProvider: incidentArchiveFixture{window}}) + for _, id := range []string{"vm-1", "vm-2"} { + result, err := exec.executeGetIncidentWindow(context.Background(), map[string]interface{}{"resource_id": id, "window_id": window.ID}) + require.NoError(t, err) + if id == window.ResourceID { + require.False(t, result.IsError) + require.Contains(t, result.Content[0].Text, "legacy_incident_recording") + require.Contains(t, result.Content[0].Text, "cached observations") + } else { + require.True(t, result.IsError) + require.NotContains(t, result.Content[0].Text, "vm-1") + } + } +} diff --git a/internal/ai/tools/tools_knowledge.go b/internal/ai/tools/tools_knowledge.go index 58c987764..7af911beb 100644 --- a/internal/ai/tools/tools_knowledge.go +++ b/internal/ai/tools/tools_knowledge.go @@ -3,9 +3,11 @@ package tools import ( "context" "fmt" + "strings" "time" "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) // IncidentRecorderProvider provides access to incident recording data @@ -87,7 +89,7 @@ func (e *PulseToolExecutor) registerKnowledgeTools() { Actions: - remember: Save a note about a resource for future reference - recall: Retrieve saved notes about a resource -- incidents: Get high-resolution incident recording data +- incidents: Read retained canonical resource history, including observed state changes, alerts and executed actions. Records preserve observation time, source and any known occurrence time. This is not continuous health or filesystem-capacity coverage. Use pulse_summarize for retained metrics. - correlate: Get correlated events around a timestamp Examples: @@ -105,7 +107,7 @@ Examples: }, "resource_id": { Type: "string", - Description: "Resource ID to operate on", + Description: "Resource ID to operate on. For incidents use the canonical resource ID returned by pulse_query, including for a resource no longer in current inventory.", }, "note": { Type: "string", @@ -117,7 +119,11 @@ Examples: }, "window_id": { Type: "string", - Description: "For incidents: specific incident window ID", + Description: "For incidents: optional legacy recording ID, read as an archive only. Omit to read canonical resource history.", + }, + "since": { + Type: "string", + Description: "For incidents: earliest observation timestamp (RFC3339, default 24 hours ago). Retention and collection gaps still apply.", }, "timestamp": { Type: "string", @@ -129,7 +135,7 @@ Examples: }, "limit": { Type: "integer", - Description: "For incidents: max windows to return (default: 5)", + Description: "For incidents: maximum retained events to return, newest first (default 50, range 1-200)", }, }, Required: []string{"action", "resource_id"}, @@ -168,38 +174,77 @@ func (e *PulseToolExecutor) executeKnowledge(ctx context.Context, args map[strin func (e *PulseToolExecutor) executeGetIncidentWindow(_ context.Context, args map[string]interface{}) (CallToolResult, error) { resourceID, _ := args["resource_id"].(string) + resourceID = strings.TrimSpace(resourceID) windowID, _ := args["window_id"].(string) - limit := intArg(args, "limit", 5) if resourceID == "" { return NewErrorResult(fmt.Errorf("resource_id is required")), nil } - if e.incidentRecorderProvider == nil { - return NewTextResult("Incident recording data not available. The incident recorder may not be enabled."), nil - } - - // If a specific window ID is requested + // Isolate legacy recordings from the canonical timeline. Their sample times + // are recorder timestamps, not verified source observation timestamps. if windowID != "" { + if e.incidentRecorderProvider == nil { + return NewErrorResult(fmt.Errorf("legacy incident recording archive is unavailable")), nil + } window := e.incidentRecorderProvider.GetWindow(windowID) - if window == nil { - return NewTextResult(fmt.Sprintf("Incident window '%s' not found.", windowID)), nil + if window == nil || window.ResourceID != resourceID { + return NewErrorResult(fmt.Errorf("legacy incident recording not found for the requested resource")), nil } return NewJSONResult(map[string]interface{}{ - "window": window, + "source": "legacy_incident_recording", + "window": window, + "evidence_limit": "Recording timestamps do not establish when the source measured each value. Repeated values may be cached observations. This archive is not the canonical incident timeline.", }), nil } - // Get windows for the resource - windows := e.incidentRecorderProvider.GetWindowsForResource(resourceID, limit) - if len(windows) == 0 { - return NewTextResult(fmt.Sprintf("No incident recording data found for resource '%s'. Incident data is captured when alerts fire.", resourceID)), nil + limit := intArg(args, "limit", 50) + if limit < 1 || limit > 200 { + return NewErrorResult(fmt.Errorf("limit must be between 1 and 200")), nil + } + queriedAt := time.Now().UTC() + since := queriedAt.Add(-24 * time.Hour) + if value, exists := args["since"]; exists { + text, ok := value.(string) + if !ok { + return NewErrorResult(fmt.Errorf("since must be an RFC3339 timestamp")), nil + } + var err error + since, err = time.Parse(time.RFC3339, text) + if err != nil || since.After(queriedAt) { + return NewErrorResult(fmt.Errorf("since must be an RFC3339 timestamp no later than now")), nil + } + } + if e.actionAuditStore == nil { + return NewErrorResult(fmt.Errorf("canonical resource history is unavailable")), nil + } + // This organization-pinned store is also used by the resource history API + // and Assistant handoffs. Do not reconstruct history from current metrics, + // match resource names, or include adjacent resources implicitly. + events, err := e.actionAuditStore.GetRecentChanges(resourceID, since, limit+1) + if err != nil { + return NewErrorResult(fmt.Errorf("read canonical resource history: %w", err)), nil + } + hasMore := len(events) > limit + if hasMore { + events = events[:limit] + } + if events == nil { + events = []unifiedresources.ResourceChange{} } return NewJSONResult(map[string]interface{}{ - "resource_id": resourceID, - "windows": windows, - "count": len(windows), + "resource_id": resourceID, + "source": "canonical_resource_timeline", + "since": since, + "queried_at": queriedAt, + "time_basis": "observed_at", + "events": events, + "count": len(events), + "limit": limit, + "has_more": hasMore, + "coverage": "retained_records_only", + "evidence_limit": "These are retained observations, not continuous coverage. Empty history does not establish health or absence of incidents. ObservedAt is when Pulse observed a change, while OccurredAt is present only when its occurrence time is known. An alert resolving establishes that alert's recovery, not its cause or a verified action outcome.", }), nil } From 919331d5b3f6076f8616b07eb8e9ca611f26ee52 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:23:30 +0100 Subject: [PATCH 8/9] Join Docker alert events with canonical resource history Keep alert and inventory evidence together after container removal and restart without rewriting retained events or transferring approval and operator authority. Resolve exact source identities in the shared store and preserve event replay idempotency across old and current records. Verify actual retained homelab events, registered tool reads, lifecycle callbacks, tenant isolation, race behaviour and responsive evidence views. --- .../PATROL_ASSISTANT_CUSTOMER_JOURNEY.md | 49 +++++ docs/release-control/v6/internal/status.json | 2 +- .../v6/internal/subsystems/monitoring.md | 9 + .../v6/internal/subsystems/registry.json | 151 ++++++++------- .../internal/subsystems/unified-resources.md | 11 ++ frontend-modern/browser-verification.json | 21 ++- internal/ai/tools/incident_history_test.go | 33 ++++ .../monitoring/monitor_alert_handling_test.go | 71 +++++++ internal/unifiedresources/history_identity.go | 161 ++++++++++++++++ .../unifiedresources/history_identity_test.go | 175 ++++++++++++++++++ internal/unifiedresources/monitor_adapter.go | 27 +++ internal/unifiedresources/store.go | 53 +++++- .../release_control/subsystem_lookup_test.py | 2 + 13 files changed, 678 insertions(+), 87 deletions(-) create mode 100644 internal/unifiedresources/history_identity.go create mode 100644 internal/unifiedresources/history_identity_test.go diff --git a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md index d7b545793..3ab223ec0 100644 --- a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md +++ b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md @@ -1886,3 +1886,52 @@ The provider warning stayed visible and no retry, route switch or provider request was made. This is captured-response rendering, not real-model diagnosis or server persistence qualification. The exact scoped worker hook gates delivery through PR #1935. + + +## Shared Docker history identity, 2026-09-06 + +The preceding incident-read correction was pushed as +`580a246981c76b401e9007f5ac65c89355b64c6d` in PR #1935. Its live retained +records established the identity split addressed here. + +The canonical fix belongs to the shared monitor/store boundary. Exact full +Docker container references resolve through current registry identity. Retained +bindings survive inventory removal, and deterministic source-specific identities +allow legacy records to be found after restart. Names and abbreviated IDs are +not sufficient evidence. A small organization-scoped history alias index joins +readable records without rewriting event IDs, timestamps or metadata. Existing +canonical succession machinery was deliberately not used for these aliases +because it also moves operator state and action indexes. History matching must +not transfer authority. The alias index follows journal retention and separate +store connections read fresh bindings. + +Focused regression and race proofs passed on pulse-dev with Go1.26.8 and +GOMAXPROCS4. Full unifiedresources, monitoring and tools packages passed in +37.826s, 79.866s and 59.584s. They cover real alert-manager callbacks, recovery +after inventory removal, restart, replay, same-name controls, tenant isolation, +unchanged operator/approval records and registered Assistant tool reads. Scoped +history lookup measured 0.261–0.275ms with one alias and 0.317–0.336ms with +20,000 unrelated aliases, at 6,280 bytes and 94 allocations per read. These are +worker microbenchmarks, not fleet or frontend performance qualification. + +The verified worker Pro binary has SHA256 +`bb6d1508a5b4d23943c37dfc42198f132c0139805dcd1891ee18aca0a9f9dd54`. +It was installed into the local development stack and restarted healthy. Both +canonical and legacy timeline API queries now return the same seven retained +records for the removed storage fixture, including the original fired/resolved +records with exact unchanged content. The complete registered-tool rendering +matrix passed at `/patrol`, 1440x1000, 900x1000 and 390x1000. Six captured cases +include migrated history, bounded and empty results, and unavailable/failed +reads. Hover/focus, Enter/Space, deepest scrolling, Escape and controlled-session +reload preserved exact inputs, outputs and completed/failed states. Pixels were +inspected at all three widths. Source and binary hashes remained fixed. Private +receipts are `tmp/patrol-history-identity/browser/receipt.json` and +`live-history-proof.json`. Controlled session responses qualify rendering, not +server persistence or model diagnosis. The cached provider refusal remained +unchanged. No model request or infrastructure fault was made. The exact scoped +worker hook remains the delivery gate for PR #1935. + +This history correction does not qualify the failed ordinary storage diagnosis, +partial recovery claim, installed tmpfs collector, autonomous provider, approved +and rejected action outcomes, or independent Pro environments. The unused legacy +recorder/coordinator still needs retirement with its archives preserved. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index af9de87e9..abd1e8912 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -10201,7 +10201,7 @@ }, { "id": "patrol-assistant-customer-outcome-qualification", - "summary": "The redesign goal remains open, with its executable plan and detailed receipts in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. Patrol owns investigation and Assistant continues the same issue. Observations, hypotheses, accepted proposals, execution and independently verified outcomes remain distinct. The recorded baseline contains 127 paid installations, 71 Patrol-enabled, 23 with Assistant calls and fourteen verified resolutions from one installation. Schema17 outcome/provider/cost fields had no adoption. These do not establish representative success, false-alarm or missed-problem rates. Shared provenance/history/risk fixes and removal of proposal-as-proof and proxy completion policy landed through PR1928/1929. PR1934 merged canonical tool/transcript identity. PR1935 contains measurement-presence, capacity, canonical config-read, command-connectivity and tmpfs collection/query corrections through 6e18777d30f30b498def39d30016a697cabc4ea7. Exact worker hooks and named browser matrices passed, with remote landing pending. Real ordinary storage diagnosis failed by ruling out pressure without filesystem capacity evidence. Same-session recovery correctly identified present health and alert resolution but overstated continuous control health. Independent fault-intact, recovery, two-pass cleanup and persisted transcript checks passed. The current incident history correction replaces the primary legacy recorder read with the existing organization-pinned canonical resource timeline, preserving source/time semantics, bounded history, empty coverage and failed-read distinctions. Explicit legacy archive reads remain resource-bound. Its registered-tool SQLite, full tools package, focused race and final-source browser proofs pass. The exact scoped worker hook gates delivery through PR1935. Live reads of the removed storage fixture confirm that health alert fired/resolved records remain under the legacy Docker ID while creation/removal use the canonical app-container ID. This is partial incident-history remediation. The shared timeline writer and retained identity/history contract must repair that split, including removed resources, without an Assistant-local ID rewrite. The unused legacy recorder/coordinator startup needs retirement while preserving saved archives. Installed tmpfs collector and real-model interpretation remain unqualified. Other named residuals include unsupported filters, typed compatibility canonical-ID lookup, legacy direction availability, Docker-host history and responsive mount details. Claude Max explicitly refused autonomous Patrol readiness. Cached refusal and API409 enforcement remain enforced. Separate paid-provider approval is pending, with no paid request or policy bypass. Ordinary Assistant is not autonomous qualification. Required local work still includes reliable interpretation, config-read/model retest, storage/backup and approved/rejected action outcomes. Independent volunteered Pro environments remain a separate wider-readiness gate.", + "summary": "The redesign goal remains open, with its executable plan and detailed receipts in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. Patrol owns investigation and Assistant continues the same issue. Observations, hypotheses, accepted proposals, execution and independently verified outcomes remain distinct. The recorded baseline contains 127 paid installations, 71 Patrol-enabled, 23 with Assistant calls and fourteen verified resolutions from one installation. Schema17 outcome/provider/cost fields had no adoption. These do not establish representative success, false-alarm or missed-problem rates. Shared provenance/history/risk fixes and removal of proposal-as-proof and proxy completion policy landed through PR1928/1929. PR1934 merged canonical tool/transcript identity. PR1935 contains measurement-presence, capacity, canonical config-read, command-connectivity and tmpfs collection/query corrections through 6e18777d30f30b498def39d30016a697cabc4ea7. Exact worker hooks and named browser matrices passed, with remote landing pending. Real ordinary storage diagnosis failed by ruling out pressure without filesystem capacity evidence. Same-session recovery correctly identified present health and alert resolution but overstated continuous control health. Independent fault-intact, recovery, two-pass cleanup and persisted transcript checks passed. The current incident history correction replaces the primary legacy recorder read with the existing organization-pinned canonical resource timeline, preserving source/time semantics, bounded history, empty coverage and failed-read distinctions. Explicit legacy archive reads remain resource-bound. Its registered-tool SQLite, full tools package, focused race and final-source browser proofs pass. The primary history read was pushed as 580a246 in PR1935. The shared monitor/store identity correction now joins exact full Docker references with canonical container history through an organization-scoped history-only alias index. Original event content and approval/operator authority remain unchanged. Callback, removal/restart, replay, tenant/control and registered-tool tests, full affected packages, race checks and scoped lookup benchmarks passed. Final worker-build API proof returns the same seven retained homelab records under canonical and legacy queries, preserving the original fired/resolved events exactly. Six-case Playwright proof at desktop/intermediate/mobile widths passed. The exact scoped worker hook gates this delivery through PR1935. The unused legacy recorder/coordinator startup needs retirement while preserving saved archives. Installed tmpfs collector and real-model interpretation remain unqualified. Other named residuals include unsupported filters, typed compatibility canonical-ID lookup, legacy direction availability, Docker-host history and responsive mount details. Claude Max explicitly refused autonomous Patrol readiness. Cached refusal and API409 enforcement remain enforced. Separate paid-provider approval is pending, with no paid request or policy bypass. Ordinary Assistant is not autonomous qualification. Required local work still includes reliable interpretation, config-read/model retest, storage/backup and approved/rejected action outcomes. Independent volunteered Pro environments remain a separate wider-readiness gate.", "owner": "project-owner", "status": "planned", "recorded_at": "2026-09-05", diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 2533243f0..93a73e38d 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -87,6 +87,15 @@ existing cache policy. The legacy untyped-error fallback is unchanged. Retaining cached inventory does not establish a successful poll or fresh backup evidence. +Docker alert lifecycle events pass through the shared resource history identity +writer. A full Docker source reference must reach the same canonical container +history as inventory changes, including recovery after inventory removal and +restart. Existing alert lifecycle event IDs remain unchanged so replay cannot +duplicate retained events. Same-name containers and abbreviated IDs must not +join another container's history. The real alert-manager callback path is +covered by `TestDockerAlertTimelineUsesCanonicalHistoryIdentity` in +`internal/monitoring/monitor_alert_handling_test.go`. + Verification: `TestPollPBSBackups_PreservesCacheOnTransientDatastoreError` and `TestPollPBSBackups_DropsStaleCacheOnTerminalDatastoreError` in `internal/monitoring/monitor_backups_readstate_test.go` exercise actual HTTP diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 0d47d4b63..3c603599e 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -668,14 +668,14 @@ ] }, { - "path": "internal/agentcapabilities/patrol_scope_tools.go", - "rationale": "the typed Patrol resource-scope evidence mapping is both the AI runtime least-manifest projection contract and the canonical API/agent vocabulary that keeps scoped Watch and investigation tool reductions aligned across the Pulse and enterprise boundary", - "subsystems": [ - "ai-runtime", - "api-contracts" - ] - }, - { + "path": "internal/agentcapabilities/patrol_scope_tools.go", + "rationale": "the typed Patrol resource-scope evidence mapping is both the AI runtime least-manifest projection contract and the canonical API/agent vocabulary that keeps scoped Watch and investigation tool reductions aligned across the Pulse and enterprise boundary", + "subsystems": [ + "ai-runtime", + "api-contracts" + ] + }, + { "path": "internal/agentcapabilities/projection.go", "rationale": "the agent capability external-tool projection helper, normalized manifest-owned surface tool contract resolution and tools-affordance gating, manifest-owned resource-context route and argument vocabulary, operator-state capability and route vocabulary, finding workflow capability and lifecycle argument vocabulary including resolution and dismissal notes, governed action capability, route, and argument vocabulary, manifest-owned tool title and outputSchema projection, structured Pulse capability _meta, and shared tool behavior hints are both the canonical API manifest projection contract and the AI runtime adapter projection for Pulse Assistant and MCP-facing agent tools, with MCP annotation and metadata wire names confined to adapter-edge aliases", "subsystems": [ @@ -1458,8 +1458,8 @@ "internal/hostagent/action_runner_client_test.go" ] }, - { - "id": "agent-privilege-helper-runtime", + { + "id": "agent-privilege-helper-runtime", "label": "typed no-network agent privilege helper proof", "match_prefixes": [ "internal/agenthelper/" @@ -1471,11 +1471,11 @@ "test_prefixes": [ "internal/agenthelper/" ], - "exact_files": [ - "cmd/pulse-agent-helper/main_test.go", - "internal/agenthelper/container_inventory_test.go", - "internal/agenthelper/update_activation_test.go" - ] + "exact_files": [ + "cmd/pulse-agent-helper/main_test.go", + "internal/agenthelper/container_inventory_test.go", + "internal/agenthelper/update_activation_test.go" + ] }, { "id": "native-pve-action-qualification", @@ -1736,9 +1736,9 @@ "internal/hostagent/docker_lifecycle_test.go", "internal/hostagent/issue1595_sas_collection_test.go", "internal/hostagent/observer_delivery_test.go", - "internal/hostagent/package_updates_test.go", - "internal/hostagent/privilege_helper_client_test.go", - "internal/hostagent/send_report_test.go", + "internal/hostagent/package_updates_test.go", + "internal/hostagent/privilege_helper_client_test.go", + "internal/hostagent/send_report_test.go", "internal/hostagent/smartctl_standby_guard_test.go", "internal/hostagent/storage_cleanup_test.go", "internal/hostagent/unraid_test.go", @@ -1754,11 +1754,11 @@ ], "allow_same_subsystem_tests": false, "test_prefixes": [], - "exact_files": [ - "scripts/installtests/agent_state_dir_lifecycle_test.go", - "scripts/installtests/install_sh_test.go", - "scripts/installtests/safe_profile_migration_test.go" - ] + "exact_files": [ + "scripts/installtests/agent_state_dir_lifecycle_test.go", + "scripts/installtests/install_sh_test.go", + "scripts/installtests/safe_profile_migration_test.go" + ] }, { "id": "windows-agent-installer-runtime", @@ -3220,10 +3220,10 @@ "test_prefixes": [ "frontend-modern/src/api/__tests__/" ], - "exact_files": [ - "frontend-modern/src/types/api.ts", - "internal/api/action_runner_credentials_test.go", - "internal/api/ai_handlers_more_test.go", + "exact_files": [ + "frontend-modern/src/types/api.ts", + "internal/api/action_runner_credentials_test.go", + "internal/api/ai_handlers_more_test.go", "internal/api/ai_handlers_patrol_actions_additional_test.go", "internal/api/alerting/external_probe_notifications_test.go", "internal/api/audit_handlers_test.go", @@ -4803,14 +4803,14 @@ ], "allow_same_subsystem_tests": false, "test_prefixes": [], - "exact_files": [ - "pulse-enterprise:scripts/validate-pro-release-line_test.sh", - "scripts/installtests/backfill_release_assets_test.go", - "scripts/installtests/build_release_assets_test.go", - "scripts/installtests/release_ldflags_test.go", - "scripts/installtests/safe_profile_migration_test.go", - "scripts/release_control/secure_runtime_attestation_v6_test.py", - "scripts/release_control/secure_runtime_attestation_v7_test.py" + "exact_files": [ + "pulse-enterprise:scripts/validate-pro-release-line_test.sh", + "scripts/installtests/backfill_release_assets_test.go", + "scripts/installtests/build_release_assets_test.go", + "scripts/installtests/release_ldflags_test.go", + "scripts/installtests/safe_profile_migration_test.go", + "scripts/release_control/secure_runtime_attestation_v6_test.py", + "scripts/release_control/secure_runtime_attestation_v7_test.py" ] }, { @@ -5056,8 +5056,8 @@ "tests/integration/tests/16-dev-runtime-recovery.spec.ts" ] }, - { - "id": "shell-installer-runtime", + { + "id": "shell-installer-runtime", "label": "shell installer runtime proof", "match_prefixes": [], "match_files": [ @@ -5065,11 +5065,11 @@ ], "allow_same_subsystem_tests": false, "test_prefixes": [], - "exact_files": [ - "scripts/installtests/agent_state_dir_lifecycle_test.go", - "scripts/installtests/install_sh_test.go", - "scripts/installtests/safe_profile_migration_test.go" - ] + "exact_files": [ + "scripts/installtests/agent_state_dir_lifecycle_test.go", + "scripts/installtests/install_sh_test.go", + "scripts/installtests/safe_profile_migration_test.go" + ] } ], "match_files": null @@ -5461,21 +5461,21 @@ "frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts" ] }, - { - "id": "compact-info-card-consumers", - "label": "compact information card consumer proof", - "match_prefixes": [], - "match_files": [ - "frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx" - ], - "allow_same_subsystem_tests": false, - "test_prefixes": [], - "exact_files": [ - "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts" - ] - }, - { - "id": "workload-presentation-helpers", + { + "id": "compact-info-card-consumers", + "label": "compact information card consumer proof", + "match_prefixes": [], + "match_files": [ + "frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts" + ] + }, + { + "id": "workload-presentation-helpers", "label": "workload presentation helper proof", "match_prefixes": [], "match_files": [ @@ -6485,11 +6485,11 @@ "frontend-modern/src/components/Settings/useRBACFeatureGateState.ts", "frontend-modern/src/components/Settings/useRolesPanelState.ts", "frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts", - "frontend-modern/src/types/rbac.ts", + "frontend-modern/src/types/rbac.ts", "frontend-modern/src/utils/organizationRolePresentation.ts", "frontend-modern/src/utils/organizationSettingsPresentation.ts", "frontend-modern/src/utils/orgUtils.ts", - "frontend-modern/src/utils/rbacPresentation.ts", + "frontend-modern/src/utils/rbacPresentation.ts", "internal/api/access_control_handlers.go", "internal/api/enterprise_extension_rbac_admin.go", "internal/api/org_handlers.go", @@ -6514,8 +6514,8 @@ "match_prefixes": [], "match_files": [ "frontend-modern/src/api/orgs.ts", - "frontend-modern/src/api/rbac.ts", - "frontend-modern/src/types/rbac.ts" + "frontend-modern/src/api/rbac.ts", + "frontend-modern/src/types/rbac.ts" ], "allow_same_subsystem_tests": false, "test_prefixes": [], @@ -6604,8 +6604,8 @@ "frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts", "frontend-modern/src/utils/organizationRolePresentation.ts", "frontend-modern/src/utils/organizationSettingsPresentation.ts", - "frontend-modern/src/utils/orgUtils.ts", - "frontend-modern/src/utils/rbacPresentation.ts" + "frontend-modern/src/utils/orgUtils.ts", + "frontend-modern/src/utils/rbacPresentation.ts" ], "allow_same_subsystem_tests": false, "test_prefixes": [], @@ -6619,8 +6619,8 @@ "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts", "frontend-modern/src/utils/__tests__/organizationRolePresentation.test.ts", "frontend-modern/src/utils/__tests__/organizationSettingsPresentation.test.ts", - "frontend-modern/src/utils/__tests__/orgUtils.test.ts", - "frontend-modern/src/utils/__tests__/rbacPresentation.test.ts" + "frontend-modern/src/utils/__tests__/orgUtils.test.ts", + "frontend-modern/src/utils/__tests__/rbacPresentation.test.ts" ] }, { @@ -7411,7 +7411,7 @@ "pkg/audit/sqlite_logger.go", "pkg/auth/agent_credentials.go", "pkg/auth/rbac.go", - "pkg/auth/rbac_manager.go", + "pkg/auth/rbac_manager.go", "pkg/auth/sqlite_manager.go", "pkg/extensions/audit_admin.go", "pkg/server/server.go", @@ -7682,7 +7682,7 @@ "match_prefixes": [], "match_files": [ "pkg/auth/rbac.go", - "pkg/auth/rbac_manager.go", + "pkg/auth/rbac_manager.go", "pkg/auth/sqlite_manager.go", "pkg/server/server.go" ], @@ -7693,7 +7693,7 @@ "internal/api/rbac_tenant_provider_test.go", "internal/api/security_regression_test.go", "pkg/auth/rbac_manager_test.go", - "pkg/auth/sqlite_manager_queryplan_test.go", + "pkg/auth/sqlite_manager_queryplan_test.go", "pkg/auth/sqlite_manager_test.go", "pkg/server/server_test.go" ] @@ -8086,6 +8086,7 @@ "internal/unifiedresources/adapter_coverage_test.go", "internal/unifiedresources/adapters_test.go", "internal/unifiedresources/ceph_pool_health_contract_test.go", + "internal/unifiedresources/history_identity_test.go", "internal/unifiedresources/host_storage_cleanup_test.go", "internal/unifiedresources/monitor_adapter_read_state_test.go", "internal/unifiedresources/views_test.go" @@ -8483,6 +8484,7 @@ "exact_files": [ "internal/monitoring/issue1595_collection_trust_test.go", "internal/unifiedresources/availability_link_test.go", + "internal/unifiedresources/history_identity_test.go", "internal/unifiedresources/kubernetes_registry_test.go", "internal/unifiedresources/pbs_pmg_registry_test.go", "internal/unifiedresources/registry_merge_policy_test.go", @@ -8520,6 +8522,19 @@ "internal/unifiedresources/action_policy_provenance_test.go" ] }, + { + "id": "resource-history-identity", + "label": "resource history identity and authority isolation proof", + "match_prefixes": [], + "match_files": [ + "internal/unifiedresources/history_identity.go" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "internal/unifiedresources/history_identity_test.go" + ] + }, { "id": "unified-resource-runtime-support", "label": "unified resource runtime support proof", diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 077ac9f72..78dd32a8f 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -4812,6 +4812,17 @@ recent-change slice plus facet counts it actually renders. The store now also owns a `resource_changes` persistence table with `RecordChange` and `GetRecentChanges` methods so change history is queryable by canonical ID and time window. +Docker alert source references containing an exact full container ID resolve at +`MonitorAdapter.RecordChange` through the current registry, then a retained +history binding, then the deterministic source-specific container identity. +Names and shortened IDs cannot establish this binding. `history_identity.go` +owns a history-only alias index in the organization-scoped resource store. +Legacy event rows retain their IDs, resource references and timestamps. Reads +expand aliases and canonical predecessor eras without changing operator state, +action requests, approvals, links or exclusions. Separate monitor, API and +Assistant store handles must see current persisted aliases. Missing identity +storage is an error, not evidence of empty history. Retention removes an alias +only after neither identity has retained journal records. That same shared timeline vocabulary now includes the `activity` change kind for provider-read breadcrumbs such as VMware tasks and events, plus the `vmware_adapter` source-adapter token for canonical provenance drill-down. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index a87940a56..293c6f6c2 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,15 +1,18 @@ { "version": 1, - "base_sha": "6e18777d30f30b498def39d30016a697cabc4ea7", - "verified_at": "2026-09-06T14:26:55.462Z", + "base_sha": "580a246981c76b401e9007f5ac65c89355b64c6d", + "verified_at": "2026-09-06T15:06:56.468Z", "result": "passed", "changed_paths": [], "content_sha256": {}, "backend_content_sha256": { + "internal/unifiedresources/history_identity.go": "3934ac49acce29b16dd671769719112208be053a8658c678f798645a6a38395d", + "internal/unifiedresources/monitor_adapter.go": "5b51089bac8e85e7c776956c17471599ec15add4e5e8502cd8687c4f788fcdb1", + "internal/unifiedresources/store.go": "018176a4762b06adf52c78128f8351ca93819cbfa10bf2242535fef3d7c59191", "internal/ai/tools/tools_knowledge.go": "d98ea1bab481acce5eee4988d74d364dc73c4068ca33725d843b54117cc9cc85", "internal/ai/tools/executor.go": "c476d6aec1f7925467f1547f0219e6c6265639a62b397649adc82a8ed87b7e4a" }, - "binary_sha256": "4929aeb869db54122bc5525352d3126c0e9fa7c4847e3fdfa741500600b5e00d", + "binary_sha256": "bb6d1508a5b4d23943c37dfc42198f132c0139805dcd1891ee18aca0a9f9dd54", "rendering_content_sha256": { "frontend-modern/src/components/AI/Chat/hooks/useChat.ts": "0b56b7a56e35d51ca96f0e126dd493b3164aa9e0ad4d8ae24bcf3af7a574b97c", "frontend-modern/src/components/AI/Chat/ChatMessages.tsx": "9672f7608d1e3a531c73cba20fd4a78752316783212afd0c292ddfd11d2bf371", @@ -33,13 +36,13 @@ } ], "states": [ - "Five captured registered-tool results: retained canonical lifecycle, bounded/truncated history, empty retained history, unavailable store, and failed store read.", - "Observation and optional occurrence timestamps, source metadata, empty arrays, coverage limitations and failed tool status survive rendering.", - "Existing provider route warning remains visible. No retry, route switch, model request or infrastructure mutation was performed." + "Six captured registered-tool results: migrated legacy Docker fired event plus canonical recovery, retained lifecycle, bounded history, empty history, unavailable store and failed read.", + "Exact event IDs, original resource references, observation/occurrence times and source metadata survive expansion. Empty coverage and failed tool states remain distinct.", + "Actual removed homelab container canonical and legacy timeline APIs return the same seven records, preserving both previously captured fired/resolved records exactly. Provider refusal remains enforced." ], "interactions": [ - "Hover/focus and Enter expansion, exact input/output comparison, deepest output scroll and pixel inspection at desktop/intermediate/mobile widths, Space collapse and Escape.", - "Full reload, controlled session selection, reopen each result and compare exact retained input/output and completed/failed state.", - "Final verified Pro build installed locally, healthy restart, fixed source/binary hashes before and after. Private receipt: tmp/patrol-canonical-history/browser/receipt.json. Captured response/session fixtures qualify rendering, not real-model diagnosis or backend persistence." + "Hover/focus, Enter expansion, exact input/output comparison, deepest scrolling, Space collapse and Escape at /patrol, 1440x1000, 900x1000 and 390x1000. Actual pixels inspected at all three widths.", + "Full reload, controlled session selection, reopening all six results and exact retained input/output and completed/failed comparison.", + "Final verified worker Pro build installed locally and restarted healthy. Source/binary hashes fixed before and after. Private proof: tmp/patrol-history-identity/browser/receipt.json and live-history-proof.json. Captured tool/session rendering does not qualify real-model diagnosis or server persistence. No provider call or fault injection." ] } diff --git a/internal/ai/tools/incident_history_test.go b/internal/ai/tools/incident_history_test.go index 08df16b58..0b8ff365f 100644 --- a/internal/ai/tools/incident_history_test.go +++ b/internal/ai/tools/incident_history_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "strings" "testing" "time" @@ -14,6 +15,38 @@ import ( type failedIncidentHistoryStore struct{ unifiedresources.ResourceStore } +func TestIncidentHistoryRetainsLegacyDockerLifecycle(t *testing.T) { + dir := t.TempDir() + store, err := unifiedresources.NewSQLiteResourceStore(dir, "incident-test") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + container := strings.Repeat("b", 64) + legacy := "docker:tower/" + container + canonical := unifiedresources.SourceSpecificID(unifiedresources.ResourceTypeAppContainer, unifiedresources.SourceDocker, "tower/container/"+container) + start := time.Now().UTC().Add(-time.Hour).Truncate(time.Second) + occurred := start.Add(-time.Minute) + fired := unifiedresources.ResourceChange{ID: "legacy-fired", ResourceID: legacy, ObservedAt: start.Add(time.Minute), OccurredAt: &occurred, Kind: unifiedresources.ChangeAlertFired, SourceType: unifiedresources.SourceHeuristic, Reason: "Container unhealthy"} + require.NoError(t, store.RecordChange(fired)) + require.NoError(t, store.Close()) + store, err = unifiedresources.NewSQLiteResourceStore(dir, "incident-test") + require.NoError(t, err) + resolved := unifiedresources.ResourceChange{ID: "canonical-resolved", ResourceID: canonical, ObservedAt: start.Add(3 * time.Minute), Kind: unifiedresources.ChangeAlertResolved, SourceType: unifiedresources.SourceHeuristic} + require.NoError(t, store.RecordChange(resolved)) + exec := NewPulseToolExecutor(ExecutorConfig{ActionAuditStore: store}) + input := map[string]interface{}{"action": "incidents", "resource_id": canonical, "since": start.Format(time.RFC3339), "limit": float64(50)} + result, err := exec.registry.Execute(context.Background(), exec, agentcapabilities.PulseKnowledgeToolName, input) + require.NoError(t, err) + require.False(t, result.IsError, result.Content) + var got struct { + Events []unifiedresources.ResourceChange `json:"events"` + } + require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &got)) + require.Equal(t, []unifiedresources.ResourceChange{resolved, fired}, got.Events) + capture, err := json.Marshal(map[string]any{"case": "migrated Docker lifecycle", "input": input, "result": result}) + require.NoError(t, err) + t.Logf("INCIDENT_EVIDENCE %s", capture) +} + func (failedIncidentHistoryStore) GetRecentChanges(string, time.Time, int) ([]unifiedresources.ResourceChange, error) { return nil, errors.New("history store unavailable") } diff --git a/internal/monitoring/monitor_alert_handling_test.go b/internal/monitoring/monitor_alert_handling_test.go index 7c61f5146..ea2c40375 100644 --- a/internal/monitoring/monitor_alert_handling_test.go +++ b/internal/monitoring/monitor_alert_handling_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" "time" @@ -16,8 +17,78 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/notifications" unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" + "github.com/stretchr/testify/require" ) +func TestDockerAlertTimelineUsesCanonicalHistoryIdentity(t *testing.T) { + dir := t.TempDir() + store, err := unifiedresources.NewSQLiteResourceStore(dir, "default") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + manager := alerts.NewManagerWithDataDir(t.TempDir()) + t.Cleanup(manager.Stop) + config := manager.GetConfig() + config.Enabled = true + config.ActivationState = alerts.ActivationPending + config.TimeThresholds = map[string]int{} + config.SuppressionWindow = 0 + manager.UpdateConfig(config) + containerID := strings.Repeat("f", 64) + host := models.DockerHost{ID: "history-host", Hostname: "history-host", LastSeen: time.Now(), Containers: []models.DockerContainer{ + {ID: containerID, Name: "worker", State: "running", Health: "unhealthy"}, + {ID: strings.Repeat("a", 64), Name: "worker", State: "running", Health: "healthy"}, + }} + registry := unifiedresources.NewRegistry(store) + registry.IngestSnapshot(models.StateSnapshot{DockerHosts: []models.DockerHost{host}}) + adapter := unifiedresources.NewMonitorAdapter(registry) + monitor := &Monitor{alertManager: manager, resourceStore: adapter} + manager.SubscribeLifecycleCallback(monitor.handleAlertLifecycleEvent) + manager.CheckDockerHost(host) + canonicalID := unifiedresources.SourceSpecificID(unifiedresources.ResourceTypeAppContainer, unifiedresources.SourceDocker, host.ID+"/container/"+containerID) + filters := unifiedresources.ResourceChangeFilters{Kinds: []unifiedresources.ChangeKind{unifiedresources.ChangeAlertFired, unifiedresources.ChangeAlertResolved}} + changes, err := store.GetRecentChangesFiltered(canonicalID, time.Time{}, 10, filters) + require.NoError(t, err) + require.Len(t, changes, 1) + require.Equal(t, unifiedresources.ChangeAlertFired, changes[0].Kind) + require.Equal(t, canonicalID, changes[0].ResourceID) + var fired alerts.Alert + for _, alert := range manager.GetActiveAlerts() { + if alert.Type == "docker-container-health" { + fired = alert + } + } + require.NotEmpty(t, fired.ID) + // Recovery is emitted after the monitored container has left the registry. + // Its exact retained source binding must still select the original resource. + monitor.resourceStore = unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(store)) + host.Containers[0].Health = "healthy" + manager.CheckDockerHost(host) + changes, err = store.GetRecentChangesFiltered(canonicalID, time.Time{}, 10, filters) + require.NoError(t, err) + require.Len(t, changes, 2) + require.Equal(t, unifiedresources.ChangeAlertResolved, changes[0].Kind) + require.Equal(t, canonicalID, changes[0].ResourceID) + monitor.recordAlertTimelineChange(&fired, unifiedresources.ChangeAlertFired, fired.StartTime, "") + monitor.recordAlertTimelineChange(&fired, unifiedresources.ChangeAlertResolved, *changes[0].OccurredAt, "") + again, err := store.GetRecentChangesFiltered(canonicalID, time.Time{}, 10, filters) + require.NoError(t, err) + require.Equal(t, changes, again) + controlID := unifiedresources.SourceSpecificID(unifiedresources.ResourceTypeAppContainer, unifiedresources.SourceDocker, host.ID+"/container/"+host.Containers[1].ID) + control, err := store.GetRecentChangesFiltered(controlID, time.Time{}, 10, filters) + require.NoError(t, err) + require.Empty(t, control) + require.NoError(t, store.Close()) + restarted, err := unifiedresources.NewSQLiteResourceStore(dir, "default") + require.NoError(t, err) + defer restarted.Close() + afterRestart, err := restarted.GetRecentChangesFiltered(canonicalID, time.Time{}, 10, filters) + require.NoError(t, err) + require.Equal(t, changes, afterRestart) + encoded, err := json.Marshal(afterRestart) + require.NoError(t, err) + t.Logf("DOCKER_HISTORY_LIFECYCLE %s", encoded) +} + func TestMonitor_HandleAlertFired_Extra(t *testing.T) { // 1. Alert is nil m1 := &Monitor{} diff --git a/internal/unifiedresources/history_identity.go b/internal/unifiedresources/history_identity.go new file mode 100644 index 000000000..e71c71a2c --- /dev/null +++ b/internal/unifiedresources/history_identity.go @@ -0,0 +1,161 @@ +package unifiedresources + +import ( + "database/sql" + "encoding/hex" + "fmt" + "strings" +) + +// 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. +func legacyDockerHistoryIdentity(ref string) (sourceID, canonicalID string, ok bool) { + ref = strings.TrimSpace(ref) + if !strings.HasPrefix(ref, "docker:") { + return "", "", false + } + host, container, found := strings.Cut(strings.TrimPrefix(ref, "docker:"), "/") + if !found || host == "" || strings.TrimSpace(host) != host || len(container) != 64 || strings.ToLower(container) != container { + return "", "", false + } + if _, err := hex.DecodeString(container); err != nil { + return "", "", false + } + sourceID = host + "/container/" + container + if host == "container" { // DockerResourceID's explicit hostless form. + sourceID = container + } + return sourceID, SourceSpecificID(ResourceTypeAppContainer, SourceDocker, sourceID), true +} + +// resourceHistoryIdentityWriter binds a source reference to the canonical +// resource of one event. It affects history lookup only, never operator state, +// action requests, approvals or execution identities. +type resourceHistoryIdentityWriter interface { + RecordChangeWithSourceIdentity(change ResourceChange, sourceID string) error + ResolveHistorySourceIdentity(sourceID string) (string, bool, error) +} + +func (s *SQLiteResourceStore) ResolveHistorySourceIdentity(sourceID string) (string, bool, error) { + var id string + err := s.db.QueryRow(`SELECT canonical_id FROM resource_history_aliases WHERE source_id = ?`, sourceID).Scan(&id) + if err == sql.ErrNoRows { + return "", false, nil + } + return id, err == nil, err +} + +func (m *MemoryStore) ResolveHistorySourceIdentity(sourceID string) (string, bool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + id, ok := m.historyAliases[sourceID] + return id, ok, nil +} + +func (s *SQLiteResourceStore) RecordChangeWithSourceIdentity(change ResourceChange, sourceID string) error { + sourceID = CanonicalResourceID(sourceID) + canonicalID := CanonicalResourceID(change.ResourceID) + if sourceID == "" || canonicalID == "" || sourceID == canonicalID { + return s.RecordChange(change) + } + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("begin resource history identity: %w", err) + } + defer tx.Rollback() + // Registry resolution is authoritative when available. Rebinding a source + // reference affects subsequent history reads without rewriting past events. + if _, err := tx.Exec(`INSERT INTO resource_history_aliases (source_id, canonical_id) VALUES (?, ?) + ON CONFLICT(source_id) DO UPDATE SET canonical_id = excluded.canonical_id`, sourceID, canonicalID); err != nil { + return fmt.Errorf("record resource history identity: %w", err) + } + if err := recordChangeSQL(tx, change, s.resourceChangesHasTimestamp); err != nil { + return err + } + return tx.Commit() +} + +func (m *MemoryStore) RecordChangeWithSourceIdentity(change ResourceChange, sourceID string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.historyAliases == nil { + m.historyAliases = make(map[string]string) + } + if sourceID = CanonicalResourceID(sourceID); sourceID != "" && sourceID != change.ResourceID { + m.historyAliases[sourceID] = change.ResourceID + } + return m.recordChangeLocked(change) +} + +// migrateResourceHistoryAliases adds an index for exact legacy Docker event +// identities, including resources already removed from live inventory. The +// event rows and all authority-bearing tables remain unchanged. +func (s *SQLiteResourceStore) migrateResourceHistoryAliases() error { + if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS resource_history_aliases ( + source_id TEXT PRIMARY KEY, canonical_id TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS idx_resource_history_aliases_canonical ON resource_history_aliases(canonical_id);`); err != nil { + return fmt.Errorf("initialize resource history identities: %w", err) + } + rows, err := s.db.Query(`SELECT DISTINCT canonical_id FROM resource_changes WHERE canonical_id GLOB 'docker:*'`) + if err != nil { + return fmt.Errorf("read legacy resource history identities: %w", err) + } + aliases := make(map[string]string) + for rows.Next() { + var sourceID string + if err := rows.Scan(&sourceID); err != nil { + rows.Close() + return err + } + if _, canonicalID, ok := legacyDockerHistoryIdentity(sourceID); ok { + aliases[sourceID] = canonicalID + } + } + readErr := rows.Err() + rows.Close() // The store has one connection. Release it before writing. + if readErr != nil { + return readErr + } + for sourceID, canonicalID := range aliases { + if _, err := s.db.Exec(`INSERT OR IGNORE INTO resource_history_aliases (source_id, canonical_id) VALUES (?, ?)`, sourceID, canonicalID); err != nil { + return fmt.Errorf("index legacy resource history identity: %w", err) + } + } + return nil +} + +// expandHistoryAliases reads persisted identity bindings each time so separate +// monitor, API and Assistant store handles see new bindings immediately. The +// indexed traversal is restricted to the requested identities, not the fleet. +func (s *SQLiteResourceStore) expandHistoryAliases(ids []string) ([]string, error) { + if len(ids) == 0 { + return ids, nil + } + seeds := make([]string, len(ids)) + args := make([]any, len(ids)) + for i, id := range ids { + seeds[i], args[i] = "(?)", id + } + rows, err := s.db.Query(`WITH RECURSIVE history_ids(id) AS ( + VALUES `+strings.Join(seeds, ",")+` + UNION SELECT a.canonical_id FROM resource_history_aliases a JOIN history_ids h ON a.source_id = h.id + UNION SELECT a.source_id FROM resource_history_aliases a JOIN history_ids h ON a.canonical_id = h.id + UNION SELECT s.old_canonical_id FROM canonical_id_successions s JOIN history_ids h ON s.new_canonical_id = h.id + ) SELECT id FROM history_ids`, args...) + if err != nil { + return nil, fmt.Errorf("read resource history identities: %w", err) + } + defer rows.Close() + var expanded []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + expanded = append(expanded, id) + } + return expanded, rows.Err() +} diff --git a/internal/unifiedresources/history_identity_test.go b/internal/unifiedresources/history_identity_test.go new file mode 100644 index 000000000..21c4512db --- /dev/null +++ b/internal/unifiedresources/history_identity_test.go @@ -0,0 +1,175 @@ +package unifiedresources + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/stretchr/testify/require" +) + +func TestHistoryIdentityLegacyDockerReference(t *testing.T) { + container := strings.Repeat("a", 64) + for _, ref := range []string{"docker:host/" + container, "docker:container/" + container} { + source, id, ok := legacyDockerHistoryIdentity(ref) + require.True(t, ok) + require.Equal(t, SourceSpecificID(ResourceTypeAppContainer, SourceDocker, source), id) + } + for _, ref := range []string{"docker:host/worker", "docker:host/" + container[:12], "docker:host/" + strings.ToUpper(container), "docker:/" + container, "docker:host/" + strings.Repeat("z", 64), "docker:host", "vm:host/" + container} { + _, _, ok := legacyDockerHistoryIdentity(ref) + require.False(t, ok, ref) + } +} + +// Exercise the actual scoped history read as the unrelated identity index grows. +// Timing is reported for qualification, without a machine-dependent pass threshold. +func BenchmarkHistoryIdentityQuery(b *testing.B) { + for _, size := range []int{1, 20000} { + b.Run(fmt.Sprintf("aliases-%d", size), func(b *testing.B) { + store, err := NewSQLiteResourceStore(b.TempDir(), "benchmark") + require.NoError(b, err) + b.Cleanup(func() { require.NoError(b, store.Close()) }) + tx, err := store.db.Begin() + require.NoError(b, err) + for i := 0; i < size; i++ { + _, err := tx.Exec(`INSERT INTO resource_history_aliases (source_id, canonical_id) VALUES (?, ?)`, fmt.Sprintf("legacy-%d", i), fmt.Sprintf("app-container-%d", i)) + require.NoError(b, err) + require.NoError(b, recordChangeSQL(tx, ResourceChange{ID: fmt.Sprintf("event-%d", i), ResourceID: fmt.Sprintf("legacy-%d", i), ObservedAt: time.Now(), Kind: ChangeAlertFired}, store.resourceChangesHasTimestamp)) + } + require.NoError(b, tx.Commit()) + b.ResetTimer() + for i := 0; i < b.N; i++ { + got, err := store.GetRecentChanges("app-container-0", time.Time{}, 50) + if err != nil || len(got) != 1 { + b.Fatalf("scoped history: count=%d err=%v", len(got), err) + } + } + }) + } +} + +func TestHistoryIdentityMigrationPreservesEventsAndAuthority(t *testing.T) { + dir := t.TempDir() + store, err := NewSQLiteResourceStore(dir, "default") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + legacy := "docker:tower/" + strings.Repeat("b", 64) + _, canonical, _ := legacyDockerHistoryIdentity(legacy) + now := time.Now().UTC().Truncate(time.Second) + event := ResourceChange{ID: "legacy-fired", ResourceID: legacy, ObservedAt: now, Kind: ChangeAlertFired, SourceType: SourcePulseDiff, Reason: "container unhealthy", Metadata: map[string]any{"alert_id": "health-test"}} + require.NoError(t, store.RecordChange(event)) + require.NoError(t, store.SetResourceOperatorState(ResourceOperatorState{CanonicalID: legacy, NeverAutoRemediate: true, Note: "keep authority binding"})) + _, err = store.db.Exec(`INSERT INTO action_audits (id, action_id, canonical_id, request_id, created_at, updated_at, state, request_json, plan_json) + VALUES ('history-action', 'history-action', ?, 'request-1', ?, ?, 'pending', '{"binding":"original"}', '{}')`, legacy, now, now) + require.NoError(t, err) + require.NoError(t, store.Close()) + // No inventory survives this restart. The legacy full ID still identifies + // the same container, and the original event is never rewritten. + store, err = NewSQLiteResourceStore(dir, "default") + require.NoError(t, err) + for _, id := range []string{legacy, canonical} { + got, err := store.GetRecentChanges(id, now.Add(-time.Minute), 10) + require.NoError(t, err) + require.Equal(t, []ResourceChange{event}, got) + count, err := store.CountRecentChanges(id, now.Add(-time.Minute)) + require.NoError(t, err) + require.Equal(t, 1, count) + kinds, err := store.CountRecentChangesByKind(id, now.Add(-time.Minute)) + require.NoError(t, err) + require.Equal(t, 1, kinds[ChangeAlertFired]) + } + state, found, err := store.GetResourceOperatorState(legacy) + require.NoError(t, err) + require.True(t, found) + require.True(t, state.NeverAutoRemediate) + require.Equal(t, "keep authority binding", state.Note) + _, found, err = store.GetResourceOperatorState(canonical) + require.NoError(t, err) + require.False(t, found) + var actionID, request, eventID string + require.NoError(t, store.db.QueryRow(`SELECT canonical_id, request_json FROM action_audits WHERE id = 'history-action'`).Scan(&actionID, &request)) + require.Equal(t, legacy, actionID) + require.Equal(t, `{"binding":"original"}`, request) + require.NoError(t, store.db.QueryRow(`SELECT canonical_id FROM resource_changes WHERE id = 'legacy-fired'`).Scan(&eventID)) + require.Equal(t, legacy, eventID) +} + +func TestHistoryIdentitySeparateHandlesSeeBindingAndReplay(t *testing.T) { + dir := t.TempDir() + writer, err := NewSQLiteResourceStore(dir, "default") + require.NoError(t, err) + defer writer.Close() + reader, err := NewSQLiteResourceStore(dir, "default") + require.NoError(t, err) + defer reader.Close() + legacy := "docker:tower/" + strings.Repeat("c", 64) + _, canonical, _ := legacyDockerHistoryIdentity(legacy) + now := time.Now().UTC().Truncate(time.Second) + old := ResourceChange{ID: "first", ResourceID: legacy, ObservedAt: now, Kind: ChangeAlertFired, SourceType: SourcePulseDiff} + require.NoError(t, writer.RecordChange(old)) + got, err := reader.GetRecentChanges(canonical, time.Time{}, 10) + require.NoError(t, err) + require.Empty(t, got) + replayed := old + replayed.ResourceID = canonical + require.NoError(t, writer.RecordChangeWithSourceIdentity(replayed, legacy)) + second := ResourceChange{ID: "second", ResourceID: canonical, ObservedAt: now.Add(time.Second), Kind: ChangeAlertResolved, SourceType: SourcePulseDiff} + require.NoError(t, writer.RecordChangeWithSourceIdentity(second, legacy)) + require.NoError(t, writer.RecordChangeWithSourceIdentity(second, legacy)) + for _, id := range []string{legacy, canonical} { + got, err := reader.GetRecentChanges(id, time.Time{}, 10) + require.NoError(t, err) + require.Equal(t, []ResourceChange{second, old}, got) + } + // The same source identifier in a different organization cannot see this binding. + other, err := NewSQLiteResourceStore(dir, "other-org") + require.NoError(t, err) + defer other.Close() + got, err = other.GetRecentChanges(canonical, time.Time{}, 10) + require.NoError(t, err) + require.Empty(t, got) +} + +func TestHistoryIdentityMonitorAdapterUsesExactContainerIdentity(t *testing.T) { + store := NewMemoryStore() + container := strings.Repeat("d", 64) + host := models.DockerHost{ID: "tower", Hostname: "tower", LastSeen: time.Now(), Containers: []models.DockerContainer{{ID: container, Name: "worker", State: "running"}}} + registry := NewRegistry(store) + registry.IngestSnapshot(models.StateSnapshot{DockerHosts: []models.DockerHost{host}}) + adapter := NewMonitorAdapter(registry) + legacy := "docker:tower/" + container + _, canonical, _ := legacyDockerHistoryIdentity(legacy) + for i, ref := range []string{legacy, "docker:tower/worker", "docker:tower/" + container[:12]} { + require.NoError(t, adapter.RecordChange(ResourceChange{ID: ref, ResourceID: ref, Kind: ChangeAlertFired, ObservedAt: time.Now().Add(time.Duration(i) * time.Second)})) + } + got, err := store.GetRecentChanges(canonical, time.Time{}, 10) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, canonical, got[0].ResourceID) + // A retained authoritative binding survives loss of the registry. + require.NoError(t, store.RecordChangeWithSourceIdentity(ResourceChange{ID: "binding", ResourceID: "app-container-retained", ObservedAt: time.Now()}, legacy)) + removed := NewMonitorAdapter(NewRegistry(store)) + require.NoError(t, removed.RecordChange(ResourceChange{ID: "after-removal", ResourceID: legacy, Kind: ChangeAlertResolved, ObservedAt: time.Now()})) + got, err = store.GetRecentChanges("app-container-retained", time.Time{}, 10) + require.NoError(t, err) + require.Len(t, got, 2) + require.Equal(t, "app-container-retained", got[0].ResourceID) +} + +func TestHistoryIdentityRetentionAndUnavailableLookup(t *testing.T) { + store, err := NewSQLiteResourceStore(t.TempDir(), "default") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + legacy := "docker:tower/" + strings.Repeat("e", 64) + _, canonical, _ := legacyDockerHistoryIdentity(legacy) + require.NoError(t, store.RecordChangeWithSourceIdentity(ResourceChange{ID: "expired", ResourceID: canonical, ObservedAt: time.Now().Add(-2 * resourceChangesRetention)}, legacy)) + store.pruneOldRecords() + _, found, err := store.ResolveHistorySourceIdentity(legacy) + require.NoError(t, err) + require.False(t, found) + require.NoError(t, store.Close()) + _, err = store.GetRecentChanges(canonical, time.Time{}, 10) + require.Error(t, err) +} diff --git a/internal/unifiedresources/monitor_adapter.go b/internal/unifiedresources/monitor_adapter.go index 6bd9fc70e..edb0b0f4d 100644 --- a/internal/unifiedresources/monitor_adapter.go +++ b/internal/unifiedresources/monitor_adapter.go @@ -111,6 +111,33 @@ func (a *MonitorAdapter) RecordChange(change ResourceChange) error { if registry == nil || registry.store == nil { return nil } + sourceRef := change.ResourceID + if sourceID, derivedID, ok := legacyDockerHistoryIdentity(sourceRef); ok { + // Use exact source identity when inventory is present, including any + // canonical identity merge. Full Docker IDs remain derivable after removal. + registry.mu.RLock() + resolvedID := registry.bySource[SourceDocker][sourceID] + registry.mu.RUnlock() + if resolvedID != "" { + change.ResourceID = resolvedID + } else { + change.ResourceID = derivedID + if history, ok := registry.store.(resourceHistoryIdentityWriter); ok { + id, found, err := history.ResolveHistorySourceIdentity(sourceRef) + if err != nil { + return err + } + if found { + change.ResourceID = id + } + } + } + } + if sourceRef != change.ResourceID { + if writer, ok := registry.store.(resourceHistoryIdentityWriter); ok { + return writer.RecordChangeWithSourceIdentity(change, sourceRef) + } + } return registry.store.RecordChange(change) } diff --git a/internal/unifiedresources/store.go b/internal/unifiedresources/store.go index 4c9a9247c..bbbf6cc65 100644 --- a/internal/unifiedresources/store.go +++ b/internal/unifiedresources/store.go @@ -609,6 +609,9 @@ func (s *SQLiteResourceStore) initSchema() error { if err := s.ensureResourceChangesIndexes(); err != nil { return err } + if err := s.migrateResourceHistoryAliases(); err != nil { + return err + } if err := s.migrateResourceIdentitiesSchema(); err != nil { return err } @@ -1428,6 +1431,13 @@ func (s *SQLiteResourceStore) pruneOldRecords() { } else if affected > 0 { totalDeleted += affected } + // History-only aliases need not outlive all records for either identifier. + // This never removes canonical identity pins or authority-bearing state. + if _, err := s.db.Exec(`DELETE FROM resource_history_aliases + WHERE NOT EXISTS (SELECT 1 FROM resource_changes WHERE canonical_id = resource_history_aliases.source_id) + AND NOT EXISTS (SELECT 1 FROM resource_changes WHERE canonical_id = resource_history_aliases.canonical_id)`); err != nil { + log.Printf("unifiedresources: failed to prune resource history identities: %v", err) + } res, err = s.db.Exec( `DELETE FROM action_audits WHERE created_at < ?`, @@ -1614,10 +1624,10 @@ func (s *SQLiteResourceStore) queryResourceIdentityPins() ([]ResourceIdentityPin // history. Resources without pins (Proxmox guests, record-declared eras) // merge through the durable canonical_id_successions record instead. Unknown // IDs expand to themselves. -func (s *SQLiteResourceStore) resourceChangeIDSet(canonicalID string) []string { +func (s *SQLiteResourceStore) resourceChangeIDSet(canonicalID string) ([]string, error) { canonicalID = CanonicalResourceID(canonicalID) if canonicalID == "" { - return nil + return nil, nil } s.identityPinMu.Lock() @@ -1631,7 +1641,7 @@ func (s *SQLiteResourceStore) resourceChangeIDSet(canonicalID string) []string { pins := s.identityPinCache s.identityPinMu.Unlock() - return expandResourceChangeIDs(canonicalID, pins, s.successionMap()) + return s.expandHistoryAliases(expandResourceChangeIDs(canonicalID, pins, s.successionMap())) } func expandResourceChangeIDs(canonicalID string, pins []ResourceIdentityPin, successors map[string]string) []string { @@ -1757,7 +1767,11 @@ func (s *SQLiteResourceStore) GetRecentChangesFiltered(canonicalID string, since conditions := []string{} canonicalID = CanonicalResourceID(canonicalID) if canonicalID != "" { - conditions, args = appendRecentChangeResourceCondition(conditions, args, s.resourceChangeIDSet(canonicalID), filters.IncludeRelated) + ids, err := s.resourceChangeIDSet(canonicalID) + if err != nil { + return nil, err + } + conditions, args = appendRecentChangeResourceCondition(conditions, args, ids, filters.IncludeRelated) } else { conditions = append(conditions, observedAtExpr+" >= ?") args = append(args, since) @@ -1882,8 +1896,12 @@ func (s *SQLiteResourceStore) CountRecentChanges(canonicalID string, since time. } func (s *SQLiteResourceStore) CountRecentChangesFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (int, error) { + ids, err := s.resourceChangeIDSet(canonicalID) + if err != nil { + return 0, err + } query, args := buildRecentChangeCountQuery( - s.resourceChangeIDSet(canonicalID), + ids, since, filters, "SELECT COUNT(*) FROM resource_changes", @@ -1907,8 +1925,12 @@ func (s *SQLiteResourceStore) CountRecentChangesByKind(canonicalID string, since } func (s *SQLiteResourceStore) CountRecentChangesByKindFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeKind]int, error) { + ids, err := s.resourceChangeIDSet(canonicalID) + if err != nil { + return nil, err + } query, args := buildRecentChangeCountQuery( - s.resourceChangeIDSet(canonicalID), + ids, since, filters, "SELECT COALESCE(kind, ''), COUNT(*) FROM resource_changes", @@ -1950,9 +1972,13 @@ func (s *SQLiteResourceStore) CountRecentChangesBySourceType(canonicalID string, } func (s *SQLiteResourceStore) CountRecentChangesBySourceTypeFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeSourceType]int, error) { + ids, err := s.resourceChangeIDSet(canonicalID) + if err != nil { + return nil, err + } sourceTypeExpr := s.resourceChangesSourceTypeExpr() query, args := buildRecentChangeCountQuery( - s.resourceChangeIDSet(canonicalID), + ids, since, filters, "SELECT "+sourceTypeExpr+", COUNT(*) FROM resource_changes", @@ -1994,9 +2020,13 @@ func (s *SQLiteResourceStore) CountRecentChangesBySourceAdapter(canonicalID stri } func (s *SQLiteResourceStore) CountRecentChangesBySourceAdapterFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeSourceAdapter]int, error) { + ids, err := s.resourceChangeIDSet(canonicalID) + if err != nil { + return nil, err + } sourceAdapterExpr := s.resourceChangesSourceAdapterExpr() query, args := buildRecentChangeCountQuery( - s.resourceChangeIDSet(canonicalID), + ids, since, filters, "SELECT "+sourceAdapterExpr+", COUNT(*) FROM resource_changes", @@ -3352,6 +3382,7 @@ type MemoryStore struct { loopReports map[string]LoopReport identityPins map[string]ResourceIdentityPin canonicalSuccessions map[string]string + historyAliases map[string]string } func NewMemoryStore() *MemoryStore { @@ -3422,7 +3453,11 @@ func (m *MemoryStore) resourceChangeIDSetLocked(canonicalID string) []string { for _, pin := range m.identityPins { pins = append(pins, pin) } - return expandResourceChangeIDs(canonicalID, pins, m.canonicalSuccessions) + if resolved := m.historyAliases[canonicalID]; resolved != "" { + canonicalID = resolved + } + ids := expandResourceChangeIDs(canonicalID, pins, m.canonicalSuccessions) + return appendSupersededChangeIDs(ids, m.historyAliases) } func (m *MemoryStore) AddLink(link ResourceLink) error { diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 197272676..36c799ae3 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -4464,6 +4464,7 @@ class SubsystemLookupTest(unittest.TestCase): [ "internal/monitoring/issue1595_collection_trust_test.go", "internal/unifiedresources/availability_link_test.go", + "internal/unifiedresources/history_identity_test.go", "internal/unifiedresources/kubernetes_registry_test.go", "internal/unifiedresources/pbs_pmg_registry_test.go", "internal/unifiedresources/registry_merge_policy_test.go", @@ -4494,6 +4495,7 @@ class SubsystemLookupTest(unittest.TestCase): [ "internal/monitoring/issue1595_collection_trust_test.go", "internal/unifiedresources/availability_link_test.go", + "internal/unifiedresources/history_identity_test.go", "internal/unifiedresources/kubernetes_registry_test.go", "internal/unifiedresources/pbs_pmg_registry_test.go", "internal/unifiedresources/registry_merge_policy_test.go", From b0b39f00dc6685ad9ed63e8a6e91b954338073e4 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:00:24 +0100 Subject: [PATCH 9/9] Qualify Docker storage collection against a live fault Exercise the production collector through healthy, full and recovered storage states using the existing bounded disposable lab. Preserve exact mount configuration while keeping collector proof separate from model diagnosis and installed-agent qualification. --- .../PATROL_ASSISTANT_CUSTOMER_JOURNEY.md | 35 ++++- .../dockeragent/collect_tmpfs_live_test.go | 148 ++++++++++++++++++ 2 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 internal/dockeragent/collect_tmpfs_live_test.go diff --git a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md index 151103333..daa6d4acc 100644 --- a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md +++ b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md @@ -65,7 +65,7 @@ reproduction evidence, not a representative customer success rate. | 2. Shared evidence | Preserve canonical risk reasons and SMART counters, source/time semantics and history across tools/turns. | Regression tests preserve unknown versus zero and all canonical evidence. Real responses can inspect the same facts as the product. | Implemented and qualified for the named shared-evidence defects. Canonical disk detail, risk and cadence pass real data-path proof. Affected package and concurrency checks pass. Integrated CI later exposed remaining query and allocation regressions. The final bounded query-reuse correction passes complete selected exact-base worker comparisons and full metrics/database and focused race checks. Final landing CI passed and PRs #1928 and #1929 merged. Real-model interpretation failures remain tracked in step 5. | | 3. Diagnostic orchestration | Correct proposal-as-proof. Audit triage budgets, unmatched-signal evaluation, assessment completion and investigation cutoffs. | No code-written causal conclusion. No quality inferred from tool, flag or finding counts. Each retained pass has an objective reason. Safety boundaries and incomplete outcomes remain explicit. | Proposal promotion and capture inference were removed in c5d2f56dda. Commit 668af3fe6b removes investigation success-call floors, checkpoint instructions and generic call-count wrap-up rules. The detection slice removes contextless follow-up passes, flag/report-count policy and first-finding completion modes. Full chat and AI suites, focused API and conversation race tests pass. Real-model/action outcome qualification remains open. | | 4. Issue through verified outcome | Follow existing issue/investigation/action records into Assistant, approval, execution and independent readback. | Accepted proposal is visibly distinct from execution and verification. Rejected or unsupported actions do not become success. Uncertainty can survive an action proposal. | Existing foundation, full journey qualification pending. | -| 5. Ground-truth qualification and landing | Extend existing qualification tooling only where necessary. Exercise healthy/unhealthy, dependency, missing-access, storage/backup and approved/rejected action cases. Inspect the final browser journey at desktop and narrow widths. | Record exact source/model/permissions, evidence, decisions, faults/misses, latency and verification. Fix in-scope failures, pass appropriate proofs and land scoped commits. | Pending. | +| 5. Ground-truth qualification and landing | Extend existing qualification tooling only where necessary. Exercise healthy/unhealthy, dependency, missing-access, storage/backup and approved/rejected action cases. Inspect the final browser journey at desktop and narrow widths. | Record exact source/model/permissions, evidence, decisions, faults/misses, latency and verification. Fix in-scope failures, pass appropriate proofs and land scoped commits. | Partial. Regression, controlled browser and live collector evidence are recorded below. Real-model diagnosis, linked approval/action outcomes and installed collector qualification remain open. | Use one shared runtime and the existing qualification runner, not a second product intelligence engine or a new parallel lifecycle. Preserve independent @@ -2014,3 +2014,36 @@ full API rerun passes under that account (286.960s), as do the incoming startup-replay and legacy-boundary source checks. Frontend type checks and all 29 incoming alert tests pass. The exact staged hook gates landing. Private receipts are under `tmp/patrol-archive-retirement/` in the workspace. + +### Live collector storage evidence, 2026-09-06 + +`TestCollectContainerStorageFaultLive` calls the production Docker client and +`collectContainer` implementation against the existing storage-pressure lab. +The opt-in command, run on the worker beside its Docker Unix socket, is: + +```sh +PULSE_QUALIFY_ORACLE_DOCKER_CONTEXT=default go test ./internal/dockeragent -run '^TestCollectContainerStorageFaultLive$' -count=1 -timeout=240s -v +``` + +The final test passed in 8.694s on Docker 29.8.0 against the runtime source tree +of `186ce504c8f0fa6e0174f3b10f3d99f6278f10fc`. Its SHA256 is +`cdf303f4de23c020690c81b0b57190c728319788cb0aaff906b0d4d9d864541e`. +Independent filesystem observations measured 8,380,416 available bytes before +the fault, zero during it and 8,380,416 after recovery. The service stayed +running. Collected and JSON-decoded health followed healthy, unhealthy, healthy, +while the unrelated control remained healthy. Docker returned zero native +`Mounts` throughout. The report retained the single tmpfs destination, type, +8 MiB configuration options and writable setting from `HostConfig.Tmpfs`. +Configured size is not a measured capacity counter in the report. + +The test targets exact run-owned container IDs and labels. Existing fixture +cleanup passed, its second cleanup was a no-op and inventory matched the +pre-run snapshot. The test skips without explicit opt-in. All four existing +mount regression cases also pass. Private logs are in +`tmp/patrol-storage-collector-live/` in the workspace. + +This proves live collection and report serialization only. It does not send a +report to Pulse, enroll or replace a production agent, call a provider, or +qualify approval, execution, diagnosis or model recovery. No runtime or frontend +source changed in this slice, so no new browser claim is made. Prior storage +diagnosis failures and the cached autonomous-provider refusal remain open. diff --git a/internal/dockeragent/collect_tmpfs_live_test.go b/internal/dockeragent/collect_tmpfs_live_test.go new file mode 100644 index 000000000..ca0bb459b --- /dev/null +++ b/internal/dockeragent/collect_tmpfs_live_test.go @@ -0,0 +1,148 @@ +package dockeragent + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/moby/moby/client" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/qualification" + agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" + "github.com/rs/zerolog" +) + +// This opt-in proof calls the real collector against the existing bounded lab. +// It does not enroll an agent, send reports to Pulse, or invoke a model. +func TestCollectContainerStorageFaultLive(t *testing.T) { + dockerContext := os.Getenv("PULSE_QUALIFY_ORACLE_DOCKER_CONTEXT") + if dockerContext == "" { + t.Skip("set PULSE_QUALIFY_ORACLE_DOCKER_CONTEXT to an explicit disposable Docker context") + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + endpoint, err := exec.CommandContext(ctx, "docker", "context", "inspect", dockerContext, + "--format", "{{.Endpoints.docker.Host}}").Output() + if err != nil { + t.Fatal(err) + } + host := strings.TrimSpace(string(endpoint)) + if !strings.HasPrefix(host, "unix://") { + t.Fatal("run this proof beside the disposable Docker daemon using a Unix socket context") + } + moduleClient, err := newMobyDockerClient(client.WithHost(host), client.WithAPIVersionNegotiation()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = moduleClient.Close() }) + agent := &Agent{docker: moduleClient, runtime: RuntimeDocker, logger: zerolog.Nop(), + cfg: Config{CollectDiskMetrics: true}, prevContainerCPU: make(map[string]cpuSample)} + manifest, err := qualification.LoadManifest(filepath.Join("..", "..", "tests", "qualification", "patrol", + "scenarios", "investigation.docker-storage-pressure.json")) + if err != nil { + t.Fatal(err) + } + driver := qualification.NewDockerLab(nil, qualification.DockerTarget{Context: dockerContext}) + lab, prepareErr := driver.Prepare(ctx, manifest, "collector-"+time.Now().UTC().Format("20060102t150405.000000000")) + if lab != nil { + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), time.Minute) + defer cleanupCancel() + result := driver.Cleanup(cleanupCtx, manifest, lab) + encoded, _ := json.Marshal(result) + t.Logf("cleanup=%s", encoded) + if !result.Passed || !result.SecondCleanupNoop || !result.InventoryUnchanged { + t.Errorf("disposable inventory cleanup failed: %+v", result) + } + }) + } + if prepareErr != nil { + t.Fatal(prepareErr) + } + + check := func(phase, serviceHealth string, predicates []qualification.Predicate) { + t.Helper() + observations, err := driver.Observe(ctx, manifest, lab, predicates) + encoded, _ := json.Marshal(observations) + t.Logf("%s oracle=%s", phase, encoded) + if err != nil { + t.Fatal(err) + } + for _, alias := range []string{"service", "control"} { + id := lab.ResourceIDs[alias] + filters := newDockerFilters() + filters.Add("id", id) + for _, key := range []string{"io.pulse.owner", "io.pulse.profile", "io.pulse.component"} { + value := lab.BaselineStates[alias].Labels[key] + if value == "" { + t.Fatalf("missing fixture ownership label %s", key) + } + filters.Add("label", key+"="+value) + } + containers, err := moduleClient.ContainerList(ctx, dockerContainerListOptions{All: true, Filters: filters}) + if err != nil || len(containers) != 1 || containers[0].ID != id { + t.Fatalf("exact owned fixture %s unavailable: count=%d error=%v", alias, len(containers), err) + } + collected, err := agent.collectContainer(ctx, containers[0]) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(collected) + if err != nil { + t.Fatal(err) + } + var report agentsdocker.Container + if err := json.Unmarshal(encoded, &report); err != nil { + t.Fatal(err) + } + wantHealth := "healthy" + if alias == "service" { + wantHealth = serviceHealth + } + if report.ID != id || report.State != "running" || report.Health != wantHealth { + t.Fatalf("%s %s report identity/state/health mismatch: %s", phase, alias, encoded) + } + if alias == "service" { + const destination = "/var/lib/service-cache" + inspect, err := moduleClient.ContainerInspect(ctx, id) + if err != nil || inspect.HostConfig == nil { + t.Fatalf("inspect storage configuration: %v", err) + } + options, ok := inspect.HostConfig.Tmpfs[destination] + if !ok || !strings.Contains(options, "size=8388608") { + t.Fatalf("fixture tmpfs configuration missing: %q", options) + } + matches := 0 + for _, mount := range report.Mounts { + if mount.Destination == destination { + matches++ + if mount.Type != "tmpfs" || !mount.RW || mount.Mode != options { + t.Fatalf("collector changed tmpfs configuration: %+v", mount) + } + } + } + if matches != 1 { + t.Fatalf("expected one storage mount in report, got %d", matches) + } + t.Logf("%s raw_mount_count=%d native_tmpfs_options=%q", phase, len(inspect.Mounts), options) + } + t.Logf("%s %s report=%s", phase, alias, encoded) + } + } + check("baseline", "healthy", manifest.Baseline) + for _, fault := range manifest.Faults { + if err := driver.ApplyFault(ctx, manifest, lab, fault); err != nil { + t.Fatal(err) + } + check("storage-full", "unhealthy", fault.Oracle) + if err := driver.RevertFault(ctx, manifest, lab, fault); err != nil { + t.Fatal(err) + } + check("recovered", "healthy", fault.RevertOracle) + } + check("restored-baseline", "healthy", manifest.Baseline) +}