feat(devices): vm_host link groups — nest guest VMs under their host server (#2308) - #2331
Conversation
Deploying breeze with
|
| Latest commit: |
e22440f
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://29f281eb.breeze-9te.pages.dev |
| Branch Preview URL: | https://feat-2308-vm-host-link-group.breeze-9te.pages.dev |
…in, wire body (#2308) Review findings from pr-test-analyzer on PR #2331: - core.list-response-shape.test.ts: assert linkGroupId + linkGroupRole survive the core.ts select->mapper pair (the #800/#861/#1273 dropped-field failure mode — a dropped role would silently un-group every vm_host guest while all other tests stay green). Also covers the null case for unlinked devices. - DevicesPage.test.tsx: drive the full 'link-vm-host' action chain — bulk action string -> REAL LinkVmHostModal -> host pick -> confirm -> service call with (hostId, deviceIds) -> refetch; plus the failure path (error toast, modal stays open). Guards the DeviceList<->DevicesPage action-string contract against silent drift. - deviceActions.test.ts: linkDevicesVmHost wire shape — exact POST body {kind, hostDeviceId, deviceIds} the API schema validates, and error-message propagation on 400. - links.test.ts: vm_host create audit event records kind + hostDeviceId. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Review run: /pr-review-toolkit:review-pr — code-reviewer, silent-failure-hunter, pr-test-analyzer, plus a focused code-reviewer verification pass on the fix commit. Findings: 8 raised → all addressed; 0 outstanding.
Tests: affected API suites green single-fork ( Status: review-clean, awaiting maintainer merge. |
…in, wire body (#2308) Review findings from pr-test-analyzer on PR #2331: - core.list-response-shape.test.ts: assert linkGroupId + linkGroupRole survive the core.ts select->mapper pair (the #800/#861/#1273 dropped-field failure mode — a dropped role would silently un-group every vm_host guest while all other tests stay green). Also covers the null case for unlinked devices. - DevicesPage.test.tsx: drive the full 'link-vm-host' action chain — bulk action string -> REAL LinkVmHostModal -> host pick -> confirm -> service call with (hostId, deviceIds) -> refetch; plus the failure path (error toast, modal stays open). Guards the DeviceList<->DevicesPage action-string contract against silent drift. - deviceActions.test.ts: linkDevicesVmHost wire shape — exact POST body {kind, hostDeviceId, deviceIds} the API schema validates, and error-message propagation on 400. - links.test.ts: vm_host create audit event records kind + hostDeviceId. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
872cdb4 to
5932a6e
Compare
…server (#2308) Extends the #2138 device_link_groups mechanism with the reserved asymmetric kind 'vm_host': one member is the host server, the rest are its guest VMs, nested under the host's row in the Devices list. API: - devices.link_group_role column (migration 2026-07-10, idempotent; NULL for unlinked/multiboot, 'host'|'guest' for vm_host members; app-enforced values matching the kind convention; no RLS change needed — devices already covered) - createLinkGroupSchema: kind ('multiboot' default) + hostDeviceId, superRefine requires hostDeviceId for vm_host and rejects it for multiboot - POST claims host and guests in role batches under the same TOCTOU guard; PATCH add links newcomers as guests without rewriting existing roles (a re-add must never demote the host); every unlink path clears role with membership (PATCH remove, unlinkDevices, moveOrg) - dissolveLinkGroupIfBelowMinimum is kind-aware: vm_host groups also dissolve when headless (host unlinked/moved/deleted while guests remain) - devices list response carries linkGroupRole so the web list nests without a group-table join Web: - groupLinkedDevices reorders guests directly beneath their host as FULL rows (selectable, bulk-op visible) — the multiboot offline heuristics deliberately do not apply; host off-page => guests render ungrouped (pagination caveat) - DeviceList: host row chevron expand/collapse (transient state, default expanded), guest nesting glyph + left-edge bracket, collapsed summary strip; collapsed guests excluded from select-all - Bulk action "Link as VM host + guests" -> LinkVmHostModal host picker -> linkDevicesVmHost service call - Linked Profiles tab: vm_host heading, guest count, Role column, host-first sort, host-unlink warning Tests: route guards + role batches (links.test.ts), kind-aware dissolve unit suite (deviceLinkGroups.test.ts), moveOrg role-clear assertion, real-DB headless-dissolve integration case, web grouping/nesting/collapse/modal/tab suites. db:check-drift clean. Closes #2308 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…in, wire body (#2308) Review findings from pr-test-analyzer on PR #2331: - core.list-response-shape.test.ts: assert linkGroupId + linkGroupRole survive the core.ts select->mapper pair (the #800/#861/#1273 dropped-field failure mode — a dropped role would silently un-group every vm_host guest while all other tests stay green). Also covers the null case for unlinked devices. - DevicesPage.test.tsx: drive the full 'link-vm-host' action chain — bulk action string -> REAL LinkVmHostModal -> host pick -> confirm -> service call with (hostId, deviceIds) -> refetch; plus the failure path (error toast, modal stays open). Guards the DeviceList<->DevicesPage action-string contract against silent drift. - deviceActions.test.ts: linkDevicesVmHost wire shape — exact POST body {kind, hostDeviceId, deviceIds} the API schema validates, and error-message propagation on 400. - links.test.ts: vm_host create audit event records kind + hostDeviceId. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…+ dissolve observability (#2308) code-reviewer (important): vm_host nesting was gated behind the multiboot-only "Collapse linked inactive profiles" toggle — turning that preference off (an offline-noise concern) silently flattened VM nesting too. Decoupled: groupLinkedDevices' flag now gates ONLY the multiboot strip/bar heuristics; vm_host nesting always applies. The collapse toggle also now only shows for fleets with multiboot-linked devices (no dead toggle on vm_host-only fleets). silent-failure-hunter (high): host move-org / hard-delete could dissolve a vm_host group and unlink up to 9 guests with zero audit trace. Both paths now capture the dissolve result and record { linkGroupId, linkGroupDissolved } in their audit details, matching the PATCH route's dissolve bookkeeping. silent-failure-hunter (medium): the dissolve helper's missing-group-row branch (unreachable under the composite FK — corruption or RLS filtering) returned false silently; now console.error + captureException, same as the /:id/link-group dangling-reference path. silent-failure-hunter (low): unknown linkGroupRole values now warn once per value at the DevicesPage mapping (still degrade to ungrouped); the Linked Profiles Role badge renders a neutral dash instead of asserting "Guest" for null/unknown roles; collapsing a vm_host group deselects its hidden guests so a checked row can never stay a bulk-action target while invisible. Tests updated/added for each behavior; affected API+web suites green, tsc clean in both workspaces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5932a6e to
e22440f
Compare
Closes #2308
Extends the #2138
device_link_groupsmechanism (PR #2309) with the reserved asymmetric kindvm_host: one member is the host server, the rest are its guest VMs, nested under the host's row in the Devices list. Host + guests are concurrently online — no expected-offline collapse, no active-profile election; guests stay fully managed endpoints (own row, selectable, bulk-op visible). Auto-suggestion/detection is explicitly out of scope (follow-up per the issue).API
2026-07-10-vm-host-link-groups.sql—devices.link_group_role varchar(16)(idempotentADD COLUMN IF NOT EXISTS, no inner BEGIN/COMMIT). NULL for unlinked devices and multiboot peers;'host' | 'guest'for vm_host members — app-enforced values, matchingkind's varchar-without-CHECK convention. No RLS change:devicesis already covered and the Add linked device profiles for multi-boot systems #2138 composite FK keeps enforcing the same-org invariant. Invariant:link_group_id IS NULL => link_group_role IS NULL(every unlink path clears both).createLinkGroupSchema:kind('multiboot'default so existing clients are untouched) +hostDeviceId; superRefine requireshostDeviceIdfor vm_host, rejects it for multiboot, and requires it to be one ofdeviceIds.dissolveLinkGroupIfBelowMinimumis now kind-aware: any group below 2 members dissolves (unchanged); a vm_host group additionally dissolves when headless — its host was unlinked, moved org, or hard-deleted while guests remain. moveOrg and cascade-delete paths get this for free through the shared helper.linkGroupRoleper row (non-null role implies kindvm_host), so the web list nests without a group-table join. Link-group GET payloads include each member'srole.Web
groupLinkedDevicesreorders guest rows directly beneath their host as full rows withvmRole/vmGroupIdmarkers — the multiboot strip/bar heuristics deliberately never apply to vm_host groups. Host off-page → guests render ungrouped (same pagination caveat as multiboot).DeviceList: chevron expand/collapse on the host row (transient per-visit state, default expanded), left-edge bracket + corner glyph on guest rows, "N guest VMs hidden" strip when collapsed. Collapsed (hidden) guests are excluded from select-all — no invisible selection.LinkVmHostModalhost picker (radio list of the selection) →linkDevicesVmHostservice call; toasts + refetch mirror the multiboot path.Verification
apps/api:links.test.ts(8 new vm_host cases), newdeviceLinkGroups.test.tskind-aware dissolve suite,moveOrg.test.tsrole-clear assertion,cascadeDelete.test.ts,links.mountorder.test.ts— 55 passed, single-fork.deviceLinkGroupsRls.integration.test.ts— 6 passed, including the new vm_host headless-dissolve case (guest count ≥ 2 so only the headless rule can fire); migration applied cleanly via autoMigrate.apps/web:linkedDevices.test.ts(6 new),DeviceList.test.tsx(5 new),DeviceLinkedProfilesTab.test.tsx(3 new), newLinkVmHostModal.test.tsx— 85 passed.no-silent-mutationsguard green.tsc --noEmitclean in bothapps/apiandapps/web(no.astrotouched);pnpm db:check-driftclean (384 migrations match ledger).🤖 Generated with Claude Code