mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
b7bdf1b4eb
## Problem
`POST /api/resources/{id}/unlink` answered 200 "Resources unlinked" but
only recorded a `ResourceExclusion`. The manual link row stayed in
`resource_links`, and `ResourceRegistry.applyManualLinks` never consults
exclusions, so the pair stayed merged in the monitor's registry
rebuilds, the resources API and the websocket broadcast. Repro: link a
standalone agent into a VM, unlink it, then `GET /api/resources/<vm>`:
the agent facet is still there and the agent row is still gone.
Separately, the websocket broadcast coalesced host views with
`CoalescePresentationHostResources`, which applies no exclusions, while
REST's `ListForPresentation` applies the registry's, so a host pair the
operator split could be one broadcast row and two REST rows.
No frontend code calls `/api/resources/{id}/link` or `/unlink`
(`/api/agents/agent/unlink` is a different endpoint).
## Semantics
A resource pair carries one operator decision, and the later one
replaces the earlier: a link joins the pair, an exclusion (unlink,
report-merge) keeps it apart.
- Store writes: `AddExclusion` deletes the pair's link rows and
`AddLink` deletes its exclusion rows, in one transaction, matching both
ID orders; each also deletes a row of its own kind that canonical-ID
succession stored in the other order. The in-memory store does the same
and stamps `CreatedAt` like SQLite.
- Store reads: `GetLinks` / `GetExclusions` keep only each pair's latest
decision (`effectiveManualPairDecisions`), so rows written before this
change or rewritten onto one pair by succession resolve by their later
write; a tie keeps the pair apart.
- Registry: `loadOverrides` resolves its two reads together, so an
unlink committed between them cannot load both decisions into one
generation.
- Identity pins: while a Proxmox node was linked to an agent, the merged
resource's pin could record the node's endpoint hostname with the
agent's machine key. After unlink the node completed that key, minted
the agent's ID and folded into it by ID, a path no exclusion check sees.
A pin no longer completes the identity of a record the operator split
from the pinned resource.
- Broadcast: coalesces through `MonitorAdapter.CoalesceForPresentation`
on the read state that listed the rows when its registry is store-backed
(the store's adapter or a host-continuity overlay), else on the resource
store's adapter (the mock view lists through a store-less registry),
sharing `presentationExclusionFilter` with `ListForPresentation`.
In production REST seeds from the monitor's already-linked listing, so
an unlink shows on REST, `/api/state` and the websocket from the
monitor's next registry rebuild, the same generation in which a new link
reaches the broadcast.
## Known limits (unchanged, documented in the contract)
- Report-merge derives candidate IDs from the merged resource's type, so
it does not name the folded side of a cross-type manual link and cannot
undo one; unlink does.
- An agent's own declared node link (`resolveLinkedResource`) still
ignores exclusions.
- Presentation coalescing compares the IDs of the rows being merged, so
a third same-host fragment that merges first can carry an excluded pair
into one row (REST and broadcast now agree on this).
## Verification
- `TestResourceUnlinkSplitsManuallyLinkedPair` (new
`resources_unlink_test.go`) drives link, unlink and relink through the
API handlers against a `MonitorAdapter` on the same SQLite store,
rebuilt twice per step. It failed on main at "after unlink" exactly as
reported.
- `TestUnlinkSplitsHostPairWhosePinTookTheOtherSidesKey`: node/agent
pairs (IP and FQDN endpoints) through three rebuilds per step; both FQDN
cases fail with the pin guard removed.
- `TestManualPairDecisionReplacesThePreviousOne`,
`TestManualPairDecisionReadsResolveStoredConflictsByRecency`,
`TestRegistryResolvesDecisionsAcrossItsTwoReads`,
`TestMonitorAdapterCoalesceForPresentationHonoursExclusions`,
`TestBroadcastPresentationCoalesceHonoursMergeExclusions`,
`TestBroadcastPresentationCoalescePrefersTheListingReadState`.
- Negative controls (each fails with its fix reverted): store write
rule, newest-link-per-pair, registry combined resolution, pin guard,
broadcast exclusion coalesce, broadcast read-state preference,
store-less adapter fallback.
- Full `./internal/unifiedresources/...`,
`./internal/api/resourceapi/...` and `./internal/monitoring/...` pass.
- Live replay on private mock stacks built from main and from this
branch (`POST .../link`, `.../unlink`, `.../link` on a mock VM and a
standalone agent, then `GET /api/resources/{id}` for both). Main: after
unlink the VM still carries the agent facet and the agent row is still
404, unchanged 25 s later. This branch: after unlink the VM has no agent
facet and the agent row is back at once; relink folds it again.
Report-merge and agent-declared node links are left for separate
follow-ups.