Files
pulse/internal
rcourtman 31fcc4f4d6 Split operator-linked resources when a merge is reported (#2739)
Stacked on #2725 (Split a manually linked pair when the API unlinks it).
Until that merges this PR carries its commit too; only the last commit
is new here.

## Problem

`POST /api/resources/{id}/report-merge` (the drawer's "Split merged
resource" dialog) excluded only the candidate IDs that
`ResourceRegistry.SourceTargets` derives with
`sourceSpecificID(mergedResource.Type, source, sourceID)`. For a source
an operator link folded in, that candidate names neither side of the
link:

- an agent linked into a VM gets a `vm-` candidate;
- an agent linked with a node or a Docker host gets a source-specific
candidate, while the folded side's canonical ID derives from its machine
ID.

No `resource_links` row matched, so even with #2725's
one-decision-per-pair store rule the link survived, the pair stayed
merged on REST, `/api/state` and the websocket, and the API answered 200
"Merge reported" while the dialog toasted "Resource split applied". Two
linked agents share their only source, so the API refused them as
"Resource is not merged".

Identity merges were already handled: `findMatch` checks exactly the
candidate `SourceTargets` reports, and an agent/Docker pair merged by
machine identity stays apart through three monitor rebuilds after
report-merge. That is now pinned by a test.

## Fix

- `applyManualLinks` records each link it applies on the merged resource
(`recordManualLinkFold`, unexported `Resource.linkFolds`): the link
row's own pair plus the sources the folded side brought, carrying the
folded side's earlier folds so each link along a chain keeps its pair.
- The record rides in-memory clones. The resources API seeds its
registry from the monitor's already-linked listing and never holds the
folded row, so this is the only place it can learn the pair.
- `HandleReportMerge` excludes the pair of every link whose folded side
brought a reported source (`ResourceRegistry.ManualLinkFolds`),
alongside the existing identity-match exclusions, deduplicated in either
order. #2725's `AddExclusion` then deletes the link row, so the pair
splits from the monitor's next rebuild, as with unlink. An applied link
also counts as a merge, so two linked agents can be reported.
- A pair is recorded once. Record ingest can recreate a folded side
whose source mapping points at a holder of another type, then fold it
again.
- A link joins only the pair its row names. Canonical-ID succession
re-keys link endpoints with `UPDATE OR IGNORE` but moves `primary_id`
unconditionally. A row whose re-key collided with the successor's own
row therefore kept the retired endpoint and took the successor as its
primary, and `applyManualLinks` merged the successor through it. No
unlink or report-merge of the merged pair removed that row. A primary
naming neither side now falls back to the row's first side, which leaves
such a row inert.

The drawer sends every merged source. Source categories are not
attributed to individual contributors, so naming one that a folded side
carried undoes that link. A source that arrived along a chain undoes
every link on its way to the merged resource.

## Verification

- `TestResourceReportMergeSplitsOperatorLinkedPair`: link and
report-merge through the API handlers against a `MonitorAdapter` on the
same SQLite store, rebuilt three times per step. Cases cover VM/agent,
node/agent and agent/Docker pairs linked from either side, plus two
linked agents. With #2725's handler, 6 of the 7 cases fail: five with
"report-merge left 1 monitor resources", and the agent pair with a 400.
The exception is a node linked from its agent, because the node's
source-specific candidate happens to be its own ID.
- `TestResourceReportMergeSplitsPairBehindASuccessionShadowedRow`,
`TestResourceReportMergeSourceFilterSelectsLinks`,
`TestResourceReportMergeSplitsIdentityMergedPair`,
`TestContract_ResourceReportMergeReplacesOperatorLink` (response shape
plus link removal), `TestManualLinkFoldsNameEachLinkPairAlongAChain`
(monitor registry and a registry seeded from its listing),
`TestManualLinkFoldsRecordEachPairOnceAcrossRecordIngests`,
`TestManualLinkFoldRepeatedPairKeepsEarlierSources`,
`TestCloneResource_CarriesLinkFolds`, and the
`TestResourceAPIReportMergeExcludesRegistryLinkFolds` guardrail.
- Negative controls, each failing at the expected assertion: the handler
from #2725, the old not-merged gate, the primary honoured outside its
row, folds appended without dedupe, a repeated pair keeping only its
later sources, `applyManualLinks` without `recordManualLinkFold`, and a
clone that shares the fold slice.
- Full `./internal/unifiedresources/...`,
`./internal/api/resourceapi/...`, `./internal/truenas/...` and
`./internal/monitoring/...` pass.
2026-10-08 03:19:09 +01:00
..