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.
This commit is contained in:
rcourtman
2026-09-06 16:23:30 +01:00
parent 580a246981
commit 919331d5b3
13 changed files with 678 additions and 87 deletions
@@ -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.2610.275ms with one alias and 0.3170.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.
+1 -1
View File
@@ -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",
@@ -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
@@ -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",
@@ -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.
+12 -9
View File
@@ -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."
]
}
@@ -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")
}
@@ -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{}
@@ -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()
}
@@ -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)
}
@@ -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)
}
+44 -9
View File
@@ -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 {
@@ -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",