Skip to content

feat(devices): vm_host link groups — nest guest VMs under their host server (#2308) - #2331

Merged
ToddHebebrand merged 3 commits into
mainfrom
feat/2308-vm-host-link-groups
Jul 12, 2026
Merged

feat(devices): vm_host link groups — nest guest VMs under their host server (#2308)#2331
ToddHebebrand merged 3 commits into
mainfrom
feat/2308-vm-host-link-groups

Conversation

@ToddHebebrand

Copy link
Copy Markdown
Collaborator

Closes #2308

Extends the #2138 device_link_groups mechanism (PR #2309) 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. 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

  • Migration 2026-07-10-vm-host-link-groups.sqldevices.link_group_role varchar(16) (idempotent ADD COLUMN IF NOT EXISTS, no inner BEGIN/COMMIT). NULL for unlinked devices and multiboot peers; 'host' | 'guest' for vm_host members — app-enforced values, matching kind's varchar-without-CHECK convention. No RLS change: devices is 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 requires hostDeviceId for vm_host, rejects it for multiboot, and requires it to be one of deviceIds.
  • POST /devices/link-groups claims the host and the guests in role batches under the same TOCTOU row-count guard; PATCH add links newcomers as guests via a split claim so a no-op re-add can never demote the host; remove/moveOrg/unlink all clear the role with the membership.
  • dissolveLinkGroupIfBelowMinimum is 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.
  • Devices list response carries linkGroupRole per row (non-null role implies kind vm_host), so the web list nests without a group-table join. Link-group GET payloads include each member's role.

Web

  • groupLinkedDevices reorders guest rows directly beneath their host as full rows with vmRole/vmGroupId markers — 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.
  • New bulk action "Link as VM host + guests"LinkVmHostModal host picker (radio list of the selection) → linkDevicesVmHost service call; toasts + refetch mirror the multiboot path.
  • Linked Profiles tab: vm_host heading + guest count, Role column (Host/Guest badges), host sorted first, and a warning on "Unlink this device" when the current device is the host (unlinking it dissolves the group).

Verification

  • apps/api: links.test.ts (8 new vm_host cases), new deviceLinkGroups.test.ts kind-aware dissolve suite, moveOrg.test.ts role-clear assertion, cascadeDelete.test.ts, links.mountorder.test.ts — 55 passed, single-fork.
  • Integration (real Postgres :5433, breeze_app): 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), new LinkVmHostModal.test.tsx — 85 passed. no-silent-mutations guard green.
  • tsc --noEmit clean in both apps/api and apps/web (no .astro touched); pnpm db:check-drift clean (384 migrations match ledger).

🤖 Generated with Claude Code

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 11, 2026

Copy link
Copy Markdown

Deploying breeze with  Cloudflare Pages  Cloudflare Pages

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

View logs

ToddHebebrand pushed a commit that referenced this pull request Jul 11, 2026
…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>
@ToddHebebrand

Copy link
Copy Markdown
Collaborator Author

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.

  • pr-test-analyzer (2 + 2 optional, addressed in db0c7554): linkGroupId/linkGroupRole added to the core.list-response-shape contract test (the Server should detect main-agent silence while watchdog stays online #800/feat(devices): expose mainAgentSilentSince + watchdogStatus on list endpoint (#800 web-UI gap) #861/feat: Pending Reboot indicator on device list and detail views #1273 dropped-field failure mode — a dropped role would silently un-group every guest); the link-vm-host action-string chain now driven end-to-end in DevicesPage.test.tsx (bulk action → real modal → confirm → service args → refetch, plus the failure path); linkDevicesVmHost wire-body test; vm_host create audit assertion.
  • code-reviewer (1 important, addressed in 872cdb4c): vm_host nesting was coupled to the multiboot-only "Collapse linked inactive profiles" toggle — decoupled; the toggle now gates only the multiboot strip/bar heuristics and is hidden for vm_host-only fleets.
  • silent-failure-hunter (1 high / 1 medium / 3 low, addressed in 872cdb4c): move-org and hard-delete now record { linkGroupId, linkGroupDissolved } in audit details (a host move can unlink every guest — previously untraceable); the dissolve helper's impossible missing-group-row branch is now loud (console.error + Sentry); unknown linkGroupRole values warn once per value; the Role badge renders a dash instead of asserting "Guest" for null/unknown; collapsing a group deselects its hidden guests so no invisible row stays a bulk-action target. One accepted-by-design note: site-scope member filtering renders partial groups for restricted callers, identical to the existing multiboot posture.
  • Verification pass on 872cdb4c: clean — toggle-ON multiboot behavior byte-identical, deselect-on-collapse uses functional state correctly, audit spreads type-safe.

Tests: affected API suites green single-fork (links, deviceLinkGroups, moveOrg, cascadeDelete, core.list-response-shape, links.mountorder — 55 passed); real-DB integration deviceLinkGroupsRls green incl. the new vm_host headless-dissolve case (6 passed, breeze_app on :5433); affected web suites green (linkedDevices, DeviceList, DeviceLinkedProfilesTab, DevicesPage, LinkVmHostModal, deviceActions, no-silent-mutations — 189 passed); tsc --noEmit clean in apps/api and apps/web; pnpm db:check-drift clean.

Status: review-clean, awaiting maintainer merge.

ToddHebebrand pushed a commit that referenced this pull request Jul 11, 2026
…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>
@ToddHebebrand
ToddHebebrand force-pushed the feat/2308-vm-host-link-groups branch from 872cdb4 to 5932a6e Compare July 11, 2026 07:26
Todd Hebebrand and others added 3 commits July 12, 2026 14:46
…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>
@ToddHebebrand
ToddHebebrand force-pushed the feat/2308-vm-host-link-groups branch from 5932a6e to e22440f Compare July 12, 2026 20:51
@ToddHebebrand
ToddHebebrand merged commit c15f57f into main Jul 12, 2026
43 checks passed
@ToddHebebrand
ToddHebebrand deleted the feat/2308-vm-host-link-groups branch July 12, 2026 21:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[UI][Devices] Nest VM guests under their host server using the device link-group mechanism (#2138 follow-up)

1 participant