diff --git a/apps/api/migrations/2026-07-10-vm-host-link-groups.sql b/apps/api/migrations/2026-07-10-vm-host-link-groups.sql
new file mode 100644
index 0000000000..6c6616959f
--- /dev/null
+++ b/apps/api/migrations/2026-07-10-vm-host-link-groups.sql
@@ -0,0 +1,27 @@
+-- 2026-07-10: vm_host link groups (#2308) — member-role column on devices.
+--
+-- Extends the #2138 device_link_groups mechanism with an asymmetric kind:
+-- 'vm_host' groups one host server record with its guest-VM device records so
+-- the guests nest under the host in the device list. Unlike multiboot (peer
+-- boot profiles, only one online at a time), host + guests are concurrently
+-- online — the grouping is hierarchical organization, not offline-noise
+-- suppression, and guests stay fully managed endpoints.
+--
+-- The 2026-07-09 migration reserved exactly this design: kind='vm_host' on
+-- device_link_groups (varchar, no CHECK — app-enforced, same as 'multiboot')
+-- plus a member-role column on devices. This adds that column:
+-- link_group_role: NULL for unlinked devices and for members of symmetric
+-- kinds (multiboot); 'host' | 'guest' for members of a vm_host group
+-- (app-enforced values, matching the kind convention). Invariant:
+-- link_group_id IS NULL => link_group_role IS NULL — every unlink path
+-- (PATCH remove, group delete/dissolve, move-org) clears both together.
+--
+-- Tenancy: no RLS changes. `devices` already carries the standard org policies
+-- and the composite FK devices(link_group_id, org_id) ->
+-- device_link_groups(id, org_id) from 2026-07-09 keeps enforcing the same-org
+-- invariant; a role tag on the membership column changes nothing about scope.
+--
+-- Idempotent: ADD COLUMN IF NOT EXISTS. autoMigrate wraps the file in one
+-- transaction — no inner BEGIN/COMMIT.
+
+ALTER TABLE devices ADD COLUMN IF NOT EXISTS link_group_role varchar(16);
diff --git a/apps/api/src/__tests__/integration/deviceLinkGroupsRls.integration.test.ts b/apps/api/src/__tests__/integration/deviceLinkGroupsRls.integration.test.ts
index 0803715f08..99acdcfeb1 100644
--- a/apps/api/src/__tests__/integration/deviceLinkGroupsRls.integration.test.ts
+++ b/apps/api/src/__tests__/integration/deviceLinkGroupsRls.integration.test.ts
@@ -233,6 +233,65 @@ describe('device link-group dissolution', () => {
expect(survivor[0]?.linkGroupId).toBeNull();
});
+ runDb('vm_host group survives guest loss but dissolves headless when the host unlinks (#2308)', async () => {
+ const { orgA, deviceA1, deviceA2 } = await seed();
+
+ // deviceA1 = host, deviceA2 = guest.
+ const groupId = await withSystemDbAccessContext(async () => {
+ const [group] = await db
+ .insert(deviceLinkGroups)
+ .values({ orgId: orgA.id, kind: 'vm_host', name: 'hv-01' })
+ .returning({ id: deviceLinkGroups.id });
+ await db
+ .update(devices)
+ .set({ linkGroupId: group!.id, linkGroupRole: 'host' })
+ .where(eq(devices.id, deviceA1.id));
+ await db
+ .update(devices)
+ .set({ linkGroupId: group!.id, linkGroupRole: 'guest' })
+ .where(eq(devices.id, deviceA2.id));
+ return group!.id;
+ });
+
+ // Host + guest present: no dissolution.
+ const keptOpen = await withSystemDbAccessContext(() => dissolveLinkGroupIfBelowMinimum(db, groupId));
+ expect(keptOpen).toBe(false);
+
+ // Unlink the HOST (as a PATCH remove / move-org would): the guest remains
+ // but the group is headless — the dissolve check must remove it and clear
+ // the guest's membership AND role.
+ await withSystemDbAccessContext(async () => {
+ await db
+ .update(devices)
+ .set({ linkGroupId: null, linkGroupRole: null })
+ .where(eq(devices.id, deviceA1.id));
+ // Re-seed a second guest so the member count stays >= 2 and ONLY the
+ // headless rule (not the below-minimum rule) can dissolve the group.
+ const extra = await seedDevice(orgA.id, (await db.select({ siteId: devices.siteId }).from(devices).where(eq(devices.id, deviceA2.id)))[0]!.siteId!, `extra-${Date.now()}`);
+ await db
+ .update(devices)
+ .set({ linkGroupId: groupId, linkGroupRole: 'guest' })
+ .where(eq(devices.id, extra.id));
+ });
+
+ const dissolved = await withSystemDbAccessContext(() => dissolveLinkGroupIfBelowMinimum(db, groupId));
+ expect(dissolved).toBe(true);
+
+ const remaining = await withSystemDbAccessContext(() =>
+ db.select({ id: deviceLinkGroups.id }).from(deviceLinkGroups).where(eq(deviceLinkGroups.id, groupId)),
+ );
+ expect(remaining).toHaveLength(0);
+
+ const exGuest = await withSystemDbAccessContext(() =>
+ db
+ .select({ linkGroupId: devices.linkGroupId, linkGroupRole: devices.linkGroupRole })
+ .from(devices)
+ .where(eq(devices.id, deviceA2.id)),
+ );
+ expect(exGuest[0]?.linkGroupId).toBeNull();
+ expect(exGuest[0]?.linkGroupRole).toBeNull();
+ });
+
runDb('deleteLinkGroup unlinks every member and removes the group row', async () => {
const { orgA, deviceA1, deviceA2 } = await seed();
diff --git a/apps/api/src/db/schema/devices.ts b/apps/api/src/db/schema/devices.ts
index f5b3168b3c..77c99ae2e4 100644
--- a/apps/api/src/db/schema/devices.ts
+++ b/apps/api/src/db/schema/devices.ts
@@ -76,6 +76,14 @@ export const devices = pgTable('devices', {
// matching the users(org_id, partner_id) composite-FK convention — pins every
// member of a group to the group's org (same-org invariant).
linkGroupId: uuid('link_group_id'),
+ // Member role within an ASYMMETRIC link group (#2308). NULL for unlinked
+ // devices and for members of symmetric kinds (multiboot — all peers). For a
+ // kind='vm_host' group exactly one member is 'host' (the hypervisor/server
+ // record) and the rest are 'guest' (its VMs). Values are app-enforced
+ // ('host' | 'guest'), matching kind's varchar-without-CHECK convention.
+ // Invariant: link_group_id IS NULL => link_group_role IS NULL (every unlink
+ // path clears both together).
+ linkGroupRole: varchar('link_group_role', { length: 16 }),
tags: text('tags').array().default([]),
customFields: jsonb('custom_fields').default({}),
managementPosture: jsonb('management_posture'),
@@ -129,10 +137,10 @@ export const devices = pgTable('devices', {
export const deviceLinkGroups = pgTable('device_link_groups', {
id: uuid('id').primaryKey().defaultRandom(),
orgId: uuid('org_id').notNull().references(() => organizations.id),
- // What the link MEANS. 'multiboot' (v1): members are peer boot profiles of
- // one physical machine. Reserved future value: 'vm_host' (VM guests nested
- // under their host server) — schema accommodation only; a future asymmetric
- // kind adds a member-role column on devices (multiboot members are peers).
+ // What the link MEANS. 'multiboot' (v1, #2138): members are peer boot
+ // profiles of one physical machine. 'vm_host' (#2308): asymmetric — one
+ // member is the host server (devices.link_group_role = 'host') and the rest
+ // are its guest VMs ('guest'), nested under the host in the device list.
kind: varchar('kind', { length: 32 }).notNull().default('multiboot'),
name: varchar('name', { length: 255 }),
createdBy: uuid('created_by').references(() => users.id),
diff --git a/apps/api/src/routes/devices/core.list-response-shape.test.ts b/apps/api/src/routes/devices/core.list-response-shape.test.ts
index 6a73d5445d..5b65113d19 100644
--- a/apps/api/src/routes/devices/core.list-response-shape.test.ts
+++ b/apps/api/src/routes/devices/core.list-response-shape.test.ts
@@ -15,6 +15,12 @@ import { Hono } from 'hono';
// failure mode — `pendingReboot` is selected from the DB in core.ts but was
// omitted by the same response mapper, so the list/grid badge never rendered
// (the device-detail page worked because it returns the full row).
+//
+// Extended for #2138/#2308: the device-list link-group scalars
+// (`linkGroupId`, `linkGroupRole`) drive the client-side multiboot grouping
+// and vm_host guest nesting. If the mapper drops either, every linked device
+// silently renders ungrouped while all other tests stay green — the exact
+// dropped-field failure mode this file exists to catch.
vi.mock('../../db', () => ({
runOutsideDbContext: vi.fn((fn) => fn()),
@@ -140,6 +146,8 @@ describe('GET /devices — response shape', () => {
lastUser: null,
uptimeSeconds: null,
isHeadless: false,
+ linkGroupId: '44444444-4444-4444-8444-444444444444',
+ linkGroupRole: 'host',
createdAt: new Date('2026-05-26T19:39:57.519Z'),
updatedAt: new Date('2026-05-26T19:41:26.390Z'),
cpuModel: null,
@@ -170,6 +178,11 @@ describe('GET /devices — response shape', () => {
// #1720 — reliability score + trend surfaced for the list column.
expect(row).toHaveProperty('reliabilityScore', 42);
expect(row).toHaveProperty('reliabilityTrend', 'degrading');
+ // #2138/#2308 — link-group scalars must survive the mapper: the web list
+ // groups multiboot rows by linkGroupId and nests vm_host guests by
+ // linkGroupRole; dropping either silently un-groups every linked device.
+ expect(row).toHaveProperty('linkGroupId', '44444444-4444-4444-8444-444444444444');
+ expect(row).toHaveProperty('linkGroupRole', 'host');
});
it('returns null watchdogStatus / mainAgentSilentSince for healthy rows (still present in shape)', async () => {
@@ -201,6 +214,8 @@ describe('GET /devices — response shape', () => {
lastUser: null,
uptimeSeconds: null,
isHeadless: false,
+ linkGroupId: null,
+ linkGroupRole: null,
createdAt: new Date(),
updatedAt: new Date(),
cpuModel: null,
@@ -239,5 +254,11 @@ describe('GET /devices — response shape', () => {
expect(row.mainAgentSilentSince).toBeNull();
expect(row.watchdogVersion).toBeNull();
expect(row.pendingReboot).toBe(false);
+
+ // #2138/#2308 — link-group keys present (null) even for unlinked devices.
+ expect(Object.prototype.hasOwnProperty.call(row, 'linkGroupId')).toBe(true);
+ expect(Object.prototype.hasOwnProperty.call(row, 'linkGroupRole')).toBe(true);
+ expect(row.linkGroupId).toBeNull();
+ expect(row.linkGroupRole).toBeNull();
});
});
diff --git a/apps/api/src/routes/devices/core.ts b/apps/api/src/routes/devices/core.ts
index ae6074e8da..fe35bc9c4e 100644
--- a/apps/api/src/routes/devices/core.ts
+++ b/apps/api/src/routes/devices/core.ts
@@ -568,6 +568,10 @@ coreRoutes.get(
// Linked multi-boot profiles (#2138): null => unlinked. The web list
// groups rows client-side by this id (inactive strips / group bar).
linkGroupId: devices.linkGroupId,
+ // vm_host member role (#2308): 'host' | 'guest' | null. Lets the web
+ // list nest guest rows under their host without joining the group
+ // table — a non-null role implies the group's kind is 'vm_host'.
+ linkGroupRole: devices.linkGroupRole,
createdAt: devices.createdAt,
updatedAt: devices.updatedAt,
// Hardware summary
@@ -688,6 +692,7 @@ coreRoutes.get(
batteryStatus: d.batteryStatus ?? null,
activeVpns: d.activeVpns ?? null,
linkGroupId: d.linkGroupId ?? null,
+ linkGroupRole: d.linkGroupRole ?? null,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
cpuPercent: latestMetrics?.cpuPercent ?? 0,
@@ -1356,6 +1361,12 @@ coreRoutes.delete(
}
}
+ // #2138/#2308 — whether deleting this device dissolved its link group
+ // (lone multiboot survivor unlinked, or a vm_host group left headless and
+ // its guests unlinked). Recorded in the audit details: an unexplained
+ // "why did this whole VM group un-group?" must be traceable to this event.
+ let linkGroupDissolved = false;
+
// Cascade: remove all FK-referencing records in a transaction.
// Uses raw SQL to cover all child tables without importing each schema.
// When adding new tables with device_id FK, add them here too.
@@ -1388,10 +1399,11 @@ coreRoutes.delete(
await tx.delete(devices).where(eq(devices.id, deviceId));
// #2138 — the deleted device's link_group_id went with its row. If it
- // was a boot profile and the group now has a single lone survivor,
- // dissolve the (meaningless) group.
+ // was a boot profile and the group now has a single lone survivor —
+ // or it was a vm_host group's HOST (#2308), leaving the group
+ // headless — dissolve the group.
if (device.linkGroupId) {
- await dissolveLinkGroupIfBelowMinimum(tx, device.linkGroupId);
+ linkGroupDissolved = await dissolveLinkGroupIfBelowMinimum(tx, device.linkGroupId);
}
});
} catch (err: unknown) {
@@ -1413,7 +1425,16 @@ coreRoutes.delete(
resourceType: 'device',
resourceId: deviceId,
resourceName: device.hostname ?? device.displayName ?? deviceId,
- details: { uninstallCommandSent: uninstallSent }
+ details: {
+ uninstallCommandSent: uninstallSent,
+ // #2138/#2308 — deleting a linked device can dissolve its link group
+ // (and unlink every remaining member). Without this flag the audit
+ // trail would show only "device deleted" while sibling devices
+ // silently lost their grouping.
+ ...(device.linkGroupId
+ ? { linkGroupId: device.linkGroupId, linkGroupDissolved }
+ : {}),
+ }
});
return c.json({
diff --git a/apps/api/src/routes/devices/links.test.ts b/apps/api/src/routes/devices/links.test.ts
index 99b5801877..6a8f137858 100644
--- a/apps/api/src/routes/devices/links.test.ts
+++ b/apps/api/src/routes/devices/links.test.ts
@@ -261,6 +261,219 @@ describe('device link-group routes (create success + PATCH branches)', () => {
});
});
+describe('vm_host link groups (#2308)', () => {
+ let app: Hono;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ authState.canAccessSite = () => true;
+ app = new Hono();
+ app.route('/devices', linksRoutes);
+ });
+
+ /**
+ * tx.update chain that records every .set() payload and resolves the rows
+ * `rowsFor` chooses for that payload. The where() result is both awaitable
+ * (remove path has no .returning) and .returning-capable (claim paths).
+ */
+ function txUpdateChain(sets: unknown[], rowsFor: (s: Record) => unknown[]) {
+ return () => ({
+ set: (s: Record) => {
+ sets.push(s);
+ return {
+ where: () => ({
+ returning: () => Promise.resolve(rowsFor(s)),
+ then: (res: (v: unknown) => unknown, rej: (e: unknown) => unknown) =>
+ Promise.resolve(undefined).then(res, rej),
+ }),
+ };
+ },
+ });
+ }
+
+ it('400s a vm_host create without hostDeviceId', async () => {
+ const res = await app.request(
+ jsonReq('/devices/link-groups', 'POST', { kind: 'vm_host', deviceIds: [UUID_A, UUID_B] }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it('400s a vm_host create whose hostDeviceId is not one of deviceIds', async () => {
+ const res = await app.request(
+ jsonReq('/devices/link-groups', 'POST', {
+ kind: 'vm_host',
+ hostDeviceId: '33333333-3333-3333-3333-333333333333',
+ deviceIds: [UUID_A, UUID_B],
+ }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it('400s a multiboot create that supplies hostDeviceId (peers have no host)', async () => {
+ const res = await app.request(
+ jsonReq('/devices/link-groups', 'POST', { hostDeviceId: UUID_A, deviceIds: [UUID_A, UUID_B] }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it('creates a vm_host group: kind persisted, host claimed as host, others as guests', async () => {
+ vi.mocked(getDeviceWithOrgAndSiteCheck)
+ .mockResolvedValueOnce(mockDevice({ id: UUID_A }) as any)
+ .mockResolvedValueOnce(mockDevice({ id: UUID_B }) as any);
+
+ let insertValues: Record | undefined;
+ const updateSets: Record[] = [];
+ dbTransactionMock.mockImplementation((async (cb: any) =>
+ cb({
+ insert: () => ({
+ values: (v: Record) => {
+ insertValues = v;
+ return { returning: () => Promise.resolve([{ id: 'grp-vm' }]) };
+ },
+ }),
+ update: txUpdateChain(updateSets as unknown[], (s) =>
+ s.linkGroupRole === 'host' ? [{ id: UUID_A }] : [{ id: UUID_B }],
+ ),
+ })) as any);
+ // loadMembers for the response body.
+ dbSelectMock.mockReturnValueOnce(
+ selectChain([
+ { deviceId: UUID_A, linkGroupId: 'grp-vm', siteId: 's', hostname: 'hv-01', displayName: null, osType: 'windows', osVersion: '2022', agentVersion: '1', status: 'online', lastSeenAt: null, role: 'host' },
+ { deviceId: UUID_B, linkGroupId: 'grp-vm', siteId: 's', hostname: 'vm-01', displayName: null, osType: 'linux', osVersion: '22', agentVersion: '1', status: 'online', lastSeenAt: null, role: 'guest' },
+ ]) as any,
+ );
+
+ const res = await app.request(
+ jsonReq('/devices/link-groups', 'POST', {
+ kind: 'vm_host',
+ hostDeviceId: UUID_A,
+ deviceIds: [UUID_A, UUID_B],
+ }),
+ );
+ expect(res.status).toBe(201);
+ const body = (await res.json()) as { kind: string; members: Array<{ deviceId: string; role: string | null }> };
+ expect(body.kind).toBe('vm_host');
+ expect(insertValues?.kind).toBe('vm_host');
+ // Host batch first (role 'host', exactly the host id), then the guests.
+ expect(updateSets).toHaveLength(2);
+ expect(updateSets[0]).toMatchObject({ linkGroupId: 'grp-vm', linkGroupRole: 'host' });
+ expect(updateSets[1]).toMatchObject({ linkGroupId: 'grp-vm', linkGroupRole: 'guest' });
+ // Roles surfaced on members so the UI can nest without a second fetch.
+ expect(body.members.find((m) => m.deviceId === UUID_A)?.role).toBe('host');
+ expect(body.members.find((m) => m.deviceId === UUID_B)?.role).toBe('guest');
+ // Audit trail records the kind and the host decision.
+ const { writeRouteAudit } = await import('../../services/auditEvents');
+ expect(vi.mocked(writeRouteAudit)).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ action: 'device_link_group.create',
+ details: expect.objectContaining({ kind: 'vm_host', hostDeviceId: UUID_A }),
+ }),
+ );
+ });
+
+ it('creates a multiboot group with a single peer claim (role NULL)', async () => {
+ vi.mocked(getDeviceWithOrgAndSiteCheck)
+ .mockResolvedValueOnce(mockDevice({ id: UUID_A }) as any)
+ .mockResolvedValueOnce(mockDevice({ id: UUID_B }) as any);
+
+ const updateSets: Record[] = [];
+ dbTransactionMock.mockImplementation((async (cb: any) =>
+ cb({
+ insert: () => ({ values: () => ({ returning: () => Promise.resolve([{ id: 'grp-1' }]) }) }),
+ update: txUpdateChain(updateSets as unknown[], () => [{ id: UUID_A }, { id: UUID_B }]),
+ })) as any);
+ dbSelectMock.mockReturnValueOnce(selectChain([]) as any);
+
+ const res = await app.request(jsonReq('/devices/link-groups', 'POST', { deviceIds: [UUID_A, UUID_B] }));
+ expect(res.status).toBe(201);
+ expect((await res.json() as { kind: string }).kind).toBe('multiboot');
+ expect(updateSets).toHaveLength(1);
+ // Explicit NULL self-heals any stale role; peers never carry one.
+ expect(updateSets[0]).toMatchObject({ linkGroupRole: null });
+ });
+
+ it('409s a vm_host create when the guest claim races (host batch alone is not enough)', async () => {
+ vi.mocked(getDeviceWithOrgAndSiteCheck)
+ .mockResolvedValueOnce(mockDevice({ id: UUID_A }) as any)
+ .mockResolvedValueOnce(mockDevice({ id: UUID_B }) as any);
+ dbTransactionMock.mockImplementation((async (cb: any) =>
+ cb({
+ insert: () => ({ values: () => ({ returning: () => Promise.resolve([{ id: 'grp-vm' }]) }) }),
+ // Host batch claims its row; the guest batch loses UUID_B to a
+ // concurrent link (returns no rows) — the whole create must 409.
+ update: txUpdateChain([], (s) => (s.linkGroupRole === 'host' ? [{ id: UUID_A }] : [])),
+ })) as any);
+
+ const res = await app.request(
+ jsonReq('/devices/link-groups', 'POST', {
+ kind: 'vm_host',
+ hostDeviceId: UUID_A,
+ deviceIds: [UUID_A, UUID_B],
+ }),
+ );
+ expect(res.status).toBe(409);
+ });
+
+ it('PATCH add to a vm_host group links newcomers as guests and never rewrites existing member roles', async () => {
+ // getGroupWithOrgCheck → a vm_host group.
+ dbSelectMock.mockReturnValueOnce(
+ selectChain([{ id: 'grp-vm', orgId: 'org-123', kind: 'vm_host', name: null }]) as any,
+ );
+ // The device being added is unlinked.
+ vi.mocked(getDeviceWithOrgAndSiteCheck).mockResolvedValueOnce(mockDevice({ id: UUID_B }) as any);
+ // Current membership (size-ceiling check).
+ dbSelectMock.mockReturnValueOnce(selectChain([{ id: UUID_A }]) as any);
+
+ const updateSets: Record[] = [];
+ dbTransactionMock.mockImplementation((async (cb: any) =>
+ cb({
+ update: txUpdateChain(updateSets as unknown[], (s) =>
+ s.linkGroupRole === 'guest' ? [{ id: UUID_B }] : [],
+ ),
+ })) as any);
+ // loadMembers for the response body.
+ dbSelectMock.mockReturnValueOnce(selectChain([]) as any);
+
+ const res = await app.request(
+ jsonReq('/devices/link-groups/grp-vm', 'PATCH', { addDeviceIds: [UUID_B] }),
+ );
+ expect(res.status).toBe(200);
+
+ // Sets in order: group updatedAt touch, re-add batch, newcomer claim.
+ const reAddSet = updateSets.find((s) => 'linkGroupId' in s && !('linkGroupRole' in s));
+ const claimSet = updateSets.find((s) => s.linkGroupRole === 'guest');
+ // The re-add batch must NOT touch linkGroupRole — overwriting would
+ // demote the group's host to guest and dissolve the group.
+ expect(reAddSet).toBeDefined();
+ expect(claimSet).toMatchObject({ linkGroupId: 'grp-vm', linkGroupRole: 'guest' });
+ });
+
+ it('PATCH remove clears the member role together with the membership', async () => {
+ dbSelectMock.mockReturnValueOnce(
+ selectChain([{ id: 'grp-vm', orgId: 'org-123', kind: 'vm_host', name: null }]) as any,
+ );
+ vi.mocked(getDeviceWithOrgAndSiteCheck).mockResolvedValueOnce(
+ mockDevice({ id: UUID_B, linkGroupId: 'grp-vm' }) as any,
+ );
+ dbSelectMock.mockReturnValueOnce(selectChain([{ id: UUID_A }, { id: UUID_B }]) as any);
+
+ const updateSets: Record[] = [];
+ dbTransactionMock.mockImplementation((async (cb: any) =>
+ cb({
+ update: txUpdateChain(updateSets as unknown[], () => []),
+ })) as any);
+ dbSelectMock.mockReturnValueOnce(selectChain([]) as any);
+
+ const res = await app.request(
+ jsonReq('/devices/link-groups/grp-vm', 'PATCH', { removeDeviceIds: [UUID_B] }),
+ );
+ expect(res.status).toBe(200);
+ const unlinkSet = updateSets.find((s) => s.linkGroupId === null);
+ expect(unlinkSet).toMatchObject({ linkGroupId: null, linkGroupRole: null });
+ });
+});
+
describe('device link-group routes (site-scope member filtering)', () => {
let app: Hono;
diff --git a/apps/api/src/routes/devices/links.ts b/apps/api/src/routes/devices/links.ts
index ff0a8cac2e..81ce058443 100644
--- a/apps/api/src/routes/devices/links.ts
+++ b/apps/api/src/routes/devices/links.ts
@@ -1,6 +1,6 @@
import { Hono } from 'hono';
import { zValidator } from '../../lib/validation';
-import { and, eq, inArray, isNull, or } from 'drizzle-orm';
+import { and, eq, inArray, isNull } from 'drizzle-orm';
import { db } from '../../db';
import { deviceLinkGroups, devices } from '../../db/schema';
import { authMiddleware, requireMfa, requirePermission, requireScope } from '../../middleware/auth';
@@ -52,7 +52,7 @@ class LinkRaceError extends Error {
}
}
-/** Client-facing shape of one boot profile in a link group. */
+/** Client-facing shape of one member (boot profile / host / guest) in a link group. */
interface LinkGroupMember {
deviceId: string;
hostname: string;
@@ -62,6 +62,8 @@ interface LinkGroupMember {
agentVersion: string;
status: string;
lastSeenAt: Date | null;
+ /** 'host' | 'guest' within a vm_host group (#2308); null for multiboot peers. */
+ role: string | null;
}
/**
@@ -89,6 +91,7 @@ async function loadMembers(
agentVersion: devices.agentVersion,
status: devices.status,
lastSeenAt: devices.lastSeenAt,
+ role: devices.linkGroupRole,
})
.from(devices)
.where(inArray(devices.linkGroupId, groupIds));
@@ -106,6 +109,7 @@ async function loadMembers(
agentVersion: r.agentVersion,
status: r.status,
lastSeenAt: r.lastSeenAt,
+ role: r.role ?? null,
});
byGroup.set(r.linkGroupId, list);
}
@@ -144,7 +148,9 @@ linksRoutes.get(
},
);
-// POST /devices/link-groups — link 2+ devices as boot profiles of one machine.
+// POST /devices/link-groups — link 2+ devices as boot profiles of one machine
+// (kind='multiboot') or as one host server plus its guest VMs (kind='vm_host',
+// #2308: hostDeviceId names the host; every other member becomes a guest).
linksRoutes.post(
'/link-groups',
requireScope('organization', 'partner', 'system'),
@@ -153,7 +159,7 @@ linksRoutes.post(
zValidator('json', createLinkGroupSchema),
async (c) => {
const auth = c.get('auth');
- const { name, deviceIds } = c.req.valid('json');
+ const { kind, name, deviceIds, hostDeviceId } = c.req.valid('json');
const uniqueIds = [...new Set(deviceIds)];
if (uniqueIds.length < 2) {
@@ -190,7 +196,7 @@ linksRoutes.post(
await db.transaction(async (tx) => {
const [group] = await tx
.insert(deviceLinkGroups)
- .values({ orgId, name: name ?? null, createdBy: auth.user.id })
+ .values({ orgId, kind, name: name ?? null, createdBy: auth.user.id })
.returning({ id: deviceLinkGroups.id });
groupId = group!.id;
// Self-guarding claim: only devices STILL unlinked take the group id.
@@ -198,13 +204,28 @@ linksRoutes.post(
// link could have claimed a device in between (TOCTOU) — without the
// guard the winner's group would silently lose the device. A row-count
// mismatch aborts the whole link with a 409 instead.
- const claimed = await tx
- .update(devices)
- .set({ linkGroupId: groupId, updatedAt: new Date() })
- .where(and(inArray(devices.id, uniqueIds), isNull(devices.linkGroupId)))
- .returning({ id: devices.id });
- if (claimed.length !== uniqueIds.length) {
- throw new LinkRaceError();
+ //
+ // vm_host (#2308) claims in two batches purely because the role value
+ // differs per member (host vs guests); the guard semantics are the
+ // same — total claimed rows must equal the requested membership.
+ // Multiboot members are peers, so role stays NULL (set explicitly to
+ // self-heal any stale value, though unlink paths always clear it).
+ const batches: Array<{ ids: string[]; role: string | null }> =
+ kind === 'vm_host'
+ ? [
+ { ids: [hostDeviceId!], role: 'host' },
+ { ids: uniqueIds.filter((id) => id !== hostDeviceId), role: 'guest' },
+ ]
+ : [{ ids: uniqueIds, role: null }];
+ for (const batch of batches) {
+ const claimed = await tx
+ .update(devices)
+ .set({ linkGroupId: groupId, linkGroupRole: batch.role, updatedAt: new Date() })
+ .where(and(inArray(devices.id, batch.ids), isNull(devices.linkGroupId)))
+ .returning({ id: devices.id });
+ if (claimed.length !== batch.ids.length) {
+ throw new LinkRaceError();
+ }
}
});
} catch (err) {
@@ -220,14 +241,12 @@ linksRoutes.post(
resourceType: 'device_link_group',
resourceId: groupId!,
resourceName: name ?? null,
- details: { deviceIds: uniqueIds },
+ details: { kind, deviceIds: uniqueIds, ...(hostDeviceId ? { hostDeviceId } : {}) },
});
const members = await loadMembers([groupId!], auth);
return c.json(
- // kind is 'multiboot' for every group creatable today; surfaced so the
- // client shape is stable when future kinds (e.g. vm_host) land.
- { id: groupId!, orgId, kind: 'multiboot', name: name ?? null, members: members.get(groupId!) ?? [] },
+ { id: groupId!, orgId, kind, name: name ?? null, members: members.get(groupId!) ?? [] },
201,
);
},
@@ -347,10 +366,11 @@ linksRoutes.patch(
.where(eq(deviceLinkGroups.id, groupId));
}
if (toRemove.length > 0) {
- // Only unlink devices actually in THIS group.
+ // Only unlink devices actually in THIS group. Role is cleared with
+ // the membership — it is meaningless outside a group (#2308).
await tx
.update(devices)
- .set({ linkGroupId: null, updatedAt: new Date() })
+ .set({ linkGroupId: null, linkGroupRole: null, updatedAt: new Date() })
.where(and(inArray(devices.id, toRemove), eq(devices.linkGroupId, groupId)));
}
if (toAdd.length > 0) {
@@ -358,20 +378,36 @@ linksRoutes.patch(
// only devices still unlinked — or already in this group — take the
// group id. A concurrent link stealing one of them aborts with 409
// instead of silently succeeding with fewer members.
- const claimed = await tx
+ //
+ // Two batches so a no-op RE-add of an existing member keeps its role
+ // (#2308: overwriting would demote a vm_host group's host to guest
+ // and dissolve the group). Batch 1 touches only rows already in this
+ // group; batch 2 only still-unlinked rows — disjoint by WHERE, so
+ // the combined row count is the same guard as the single update.
+ const reAdded = await tx
.update(devices)
.set({ linkGroupId: groupId, updatedAt: new Date() })
- .where(and(
- inArray(devices.id, toAdd),
- or(isNull(devices.linkGroupId), eq(devices.linkGroupId, groupId)),
- ))
+ .where(and(inArray(devices.id, toAdd), eq(devices.linkGroupId, groupId)))
+ .returning({ id: devices.id });
+ // vm_host (#2308): a group's host is fixed at create time, so every
+ // newly linked member is a guest. Multiboot members are peers (NULL).
+ const claimed = await tx
+ .update(devices)
+ .set({
+ linkGroupId: groupId,
+ linkGroupRole: group.kind === 'vm_host' ? 'guest' : null,
+ updatedAt: new Date(),
+ })
+ .where(and(inArray(devices.id, toAdd), isNull(devices.linkGroupId)))
.returning({ id: devices.id });
- if (claimed.length !== toAdd.length) {
+ if (reAdded.length + claimed.length !== toAdd.length) {
throw new LinkRaceError();
}
}
// A group that fell below the minimum after removals is meaningless —
- // dissolve it (unlink the lone survivor, delete the row).
+ // dissolve it (unlink the lone survivor, delete the row). For vm_host
+ // this also dissolves a group whose HOST was just removed: guests
+ // without their nesting anchor are a headless group (#2308).
dissolved = await dissolveLinkGroupIfBelowMinimum(tx, groupId);
});
} catch (err) {
diff --git a/apps/api/src/routes/devices/moveOrg.test.ts b/apps/api/src/routes/devices/moveOrg.test.ts
index e40a5b0145..e141d59710 100644
--- a/apps/api/src/routes/devices/moveOrg.test.ts
+++ b/apps/api/src/routes/devices/moveOrg.test.ts
@@ -246,6 +246,9 @@ describe('POST /devices/:id/move-org', () => {
orgId: TARGET_ORG,
siteId: TARGET_SITE,
linkGroupId: null,
+ // #2308 - role travels with membership: a stale host/guest value
+ // left behind would poison the device's next link in the new org.
+ linkGroupRole: null,
});
// Two audit events, one per org
diff --git a/apps/api/src/routes/devices/moveOrg.ts b/apps/api/src/routes/devices/moveOrg.ts
index d6df7cbce7..a5e2a6f0f6 100644
--- a/apps/api/src/routes/devices/moveOrg.ts
+++ b/apps/api/src/routes/devices/moveOrg.ts
@@ -130,6 +130,11 @@ moveOrgRoutes.post(
// ----------- the actual move -----------
let updated: typeof devices.$inferSelect | undefined;
+ // #2138/#2308 — whether the move dissolved the device's old link group
+ // (lone multiboot survivor unlinked, or a vm_host group left headless
+ // when its HOST moved, unlinking every guest). Recorded in the audit
+ // details so an un-grouped fleet is traceable to this move.
+ let linkGroupDissolved = false;
try {
await db.transaction(async (tx) => {
// Flip the device row first so any concurrent agent heartbeat
@@ -143,8 +148,11 @@ moveOrgRoutes.post(
// of a machine in the OLD org. Unlink it here; the composite FK
// (link_group_id, org_id) -> device_link_groups(id, org_id) would
// otherwise fail the org flip. The source group is dissolved below
- // if it drops below the two-profile minimum.
+ // if it drops below the two-profile minimum (or, for vm_host
+ // groups, if this device WAS the host — #2308). Role travels with
+ // membership, so it clears too.
linkGroupId: null,
+ linkGroupRole: null,
updatedAt: new Date(),
})
.where(eq(devices.id, deviceId))
@@ -152,9 +160,10 @@ moveOrgRoutes.post(
updated = row;
// #2138 — if the moved device left a link group with a single lone
- // profile behind, that group is no longer meaningful: dissolve it.
+ // profile behind — or it was a vm_host group's HOST (#2308), leaving
+ // the group headless — that group is no longer meaningful: dissolve it.
if (device.linkGroupId) {
- await dissolveLinkGroupIfBelowMinimum(tx, device.linkGroupId);
+ linkGroupDissolved = await dissolveLinkGroupIfBelowMinimum(tx, device.linkGroupId);
}
// Rewrite the denormalized org_id on every device-scoped table.
@@ -229,6 +238,13 @@ moveOrgRoutes.post(
targetOrgId,
sourceSiteId: device.siteId,
targetSiteId,
+ // #2138/#2308 — a move can dissolve the device's old link group and
+ // unlink every remaining member (all guests, when a vm_host group's
+ // host moves). Without this the audit trail shows only "device moved"
+ // while sibling devices silently lost their grouping.
+ ...(device.linkGroupId
+ ? { linkGroupId: device.linkGroupId, linkGroupDissolved }
+ : {}),
} as const;
writeRouteAudit(c, {
diff --git a/apps/api/src/routes/devices/schemas.ts b/apps/api/src/routes/devices/schemas.ts
index 6d5b82848f..cfbdfc8bdf 100644
--- a/apps/api/src/routes/devices/schemas.ts
+++ b/apps/api/src/routes/devices/schemas.ts
@@ -184,13 +184,33 @@ export const createGroupSchema = z.object({
export const updateGroupSchema = createGroupSchema.partial().omit({ orgId: true });
-// Linked device profiles for multi-boot systems (#2138). A link group ties 2+
-// device records together as boot profiles of one physical machine. Sizes track
+// Device link groups (#2138 multiboot, #2308 vm_host). A link group ties 2+
+// device records together — as peer boot profiles of one physical machine
+// (multiboot) or as one host server plus its guest VMs (vm_host). Sizes track
// MIN_LINK_GROUP_SIZE / MAX_LINK_GROUP_SIZE in services/deviceLinkGroups.ts.
-export const createLinkGroupSchema = z.object({
- name: z.string().min(1).max(255).optional(),
- deviceIds: z.array(z.string().guid()).min(2).max(10),
-});
+export const LINK_GROUP_KINDS = ['multiboot', 'vm_host'] as const;
+
+export const createLinkGroupSchema = z
+ .object({
+ // Defaults to 'multiboot' so pre-#2308 clients keep working unchanged.
+ kind: z.enum(LINK_GROUP_KINDS).default('multiboot'),
+ name: z.string().min(1).max(255).optional(),
+ deviceIds: z.array(z.string().guid()).min(2).max(10),
+ // vm_host only: which member is the host server. Required for vm_host
+ // (the asymmetry is the whole point), rejected for multiboot (peers).
+ hostDeviceId: z.string().guid().optional(),
+ })
+ .superRefine((d, ctx) => {
+ if (d.kind === 'vm_host') {
+ if (!d.hostDeviceId) {
+ ctx.addIssue({ code: 'custom', path: ['hostDeviceId'], message: 'A vm_host group requires hostDeviceId' });
+ } else if (!d.deviceIds.includes(d.hostDeviceId)) {
+ ctx.addIssue({ code: 'custom', path: ['hostDeviceId'], message: 'hostDeviceId must be one of deviceIds' });
+ }
+ } else if (d.hostDeviceId !== undefined) {
+ ctx.addIssue({ code: 'custom', path: ['hostDeviceId'], message: 'hostDeviceId only applies to vm_host groups' });
+ }
+ });
export const updateLinkGroupSchema = z
.object({
diff --git a/apps/api/src/services/deviceLinkGroups.test.ts b/apps/api/src/services/deviceLinkGroups.test.ts
new file mode 100644
index 0000000000..595d4a2033
--- /dev/null
+++ b/apps/api/src/services/deviceLinkGroups.test.ts
@@ -0,0 +1,178 @@
+/**
+ * Unit tests for the link-group service helpers (#2138 multiboot, #2308
+ * vm_host). The route suites mock this module entirely, so the kind-aware
+ * dissolve rules — the load-bearing #2308 behavior — are proven here against a
+ * fake DbExecutor. Real-DB coverage of the composite-FK ordering lives in
+ * deviceLinkGroupsRls.integration.test.ts.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../db', () => ({ db: {} }));
+vi.mock('./sentry', () => ({ captureException: vi.fn() }));
+
+import { captureException } from './sentry';
+
+import {
+ dissolveLinkGroupIfBelowMinimum,
+ unlinkDevices,
+ type DbExecutor,
+} from './deviceLinkGroups';
+
+interface ExecCalls {
+ updateSets: Record[];
+ deletes: number;
+ selects: number;
+}
+
+/**
+ * Fake DbExecutor. Select #1 is the members query (awaited bare, projection
+ * {id, role}); select #2 is the group-kind lookup (.limit(1)). Updates record
+ * their .set() payloads; deletes are counted.
+ */
+function makeExec(
+ members: Array<{ id: string; role: string | null }>,
+ group: { kind: string } | undefined,
+): { exec: DbExecutor; calls: ExecCalls } {
+ const calls: ExecCalls = { updateSets: [], deletes: 0, selects: 0 };
+ const exec = {
+ select: () => {
+ calls.selects += 1;
+ const rows = calls.selects === 1 ? members : group ? [group] : [];
+ const chain = {
+ from: () => chain,
+ where: () => chain,
+ limit: () => Promise.resolve(rows),
+ then: (res: (v: unknown) => unknown, rej: (e: unknown) => unknown) =>
+ Promise.resolve(rows).then(res, rej),
+ };
+ return chain;
+ },
+ update: () => ({
+ set: (s: Record) => {
+ calls.updateSets.push(s);
+ return { where: () => Promise.resolve(undefined) };
+ },
+ }),
+ delete: () => {
+ calls.deletes += 1;
+ return { where: () => Promise.resolve(undefined) };
+ },
+ } as unknown as DbExecutor;
+ return { exec, calls };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('unlinkDevices', () => {
+ it('clears link_group_role together with link_group_id (#2308)', async () => {
+ const { exec, calls } = makeExec([], undefined);
+ await unlinkDevices(exec, ['dev-1', 'dev-2']);
+ expect(calls.updateSets).toHaveLength(2);
+ for (const set of calls.updateSets) {
+ expect(set).toMatchObject({ linkGroupId: null, linkGroupRole: null });
+ }
+ });
+
+ it('no-ops on an empty id list', async () => {
+ const { exec, calls } = makeExec([], undefined);
+ await unlinkDevices(exec, []);
+ expect(calls.updateSets).toHaveLength(0);
+ });
+});
+
+describe('dissolveLinkGroupIfBelowMinimum', () => {
+ it('leaves a multiboot group at the minimum untouched (no group lookup needed beyond kind)', async () => {
+ const { exec, calls } = makeExec(
+ [
+ { id: 'dev-1', role: null },
+ { id: 'dev-2', role: null },
+ ],
+ { kind: 'multiboot' },
+ );
+ const dissolved = await dissolveLinkGroupIfBelowMinimum(exec, 'grp-1');
+ expect(dissolved).toBe(false);
+ expect(calls.updateSets).toHaveLength(0);
+ expect(calls.deletes).toBe(0);
+ });
+
+ it('dissolves ANY kind that falls below the two-member minimum', async () => {
+ const { exec, calls } = makeExec([{ id: 'dev-1', role: 'guest' }], { kind: 'vm_host' });
+ const dissolved = await dissolveLinkGroupIfBelowMinimum(exec, 'grp-1');
+ expect(dissolved).toBe(true);
+ // The lone survivor is unlinked (role cleared too) before the group row
+ // is deleted — the composite FK forbids the reverse order.
+ expect(calls.updateSets).toHaveLength(1);
+ expect(calls.updateSets[0]).toMatchObject({ linkGroupId: null, linkGroupRole: null });
+ expect(calls.deletes).toBe(1);
+ });
+
+ it('keeps a vm_host group whose host is still a member (#2308)', async () => {
+ const { exec, calls } = makeExec(
+ [
+ { id: 'dev-host', role: 'host' },
+ { id: 'dev-vm1', role: 'guest' },
+ { id: 'dev-vm2', role: 'guest' },
+ ],
+ { kind: 'vm_host' },
+ );
+ const dissolved = await dissolveLinkGroupIfBelowMinimum(exec, 'grp-vm');
+ expect(dissolved).toBe(false);
+ expect(calls.deletes).toBe(0);
+ });
+
+ it('dissolves a HEADLESS vm_host group — guests remain but the host is gone (#2308)', async () => {
+ const { exec, calls } = makeExec(
+ [
+ { id: 'dev-vm1', role: 'guest' },
+ { id: 'dev-vm2', role: 'guest' },
+ ],
+ { kind: 'vm_host' },
+ );
+ const dissolved = await dissolveLinkGroupIfBelowMinimum(exec, 'grp-vm');
+ expect(dissolved).toBe(true);
+ // Both guests unlinked, then the group row deleted.
+ expect(calls.updateSets).toHaveLength(2);
+ for (const set of calls.updateSets) {
+ expect(set).toMatchObject({ linkGroupId: null, linkGroupRole: null });
+ }
+ expect(calls.deletes).toBe(1);
+ });
+
+ it('does NOT apply the headless rule to multiboot groups (peers never have a host)', async () => {
+ const { exec, calls } = makeExec(
+ [
+ { id: 'dev-1', role: null },
+ { id: 'dev-2', role: null },
+ { id: 'dev-3', role: null },
+ ],
+ { kind: 'multiboot' },
+ );
+ const dissolved = await dissolveLinkGroupIfBelowMinimum(exec, 'grp-1');
+ expect(dissolved).toBe(false);
+ expect(calls.deletes).toBe(0);
+ });
+
+ it('reports a missing group row LOUDLY and does not invent a dissolve', async () => {
+ // 2+ devices reference the group but its row is invisible. The composite
+ // FK makes "deleted while referenced" unreachable, so this is corruption
+ // or an RLS policy filtering the group row — surface it (console.error +
+ // Sentry), return false, touch nothing.
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { exec, calls } = makeExec(
+ [
+ { id: 'dev-1', role: null },
+ { id: 'dev-2', role: null },
+ ],
+ undefined,
+ );
+ const dissolved = await dissolveLinkGroupIfBelowMinimum(exec, 'grp-1');
+ expect(dissolved).toBe(false);
+ expect(calls.deletes).toBe(0);
+ expect(calls.updateSets).toHaveLength(0);
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('grp-1'));
+ expect(vi.mocked(captureException)).toHaveBeenCalledTimes(1);
+ errorSpy.mockRestore();
+ });
+});
diff --git a/apps/api/src/services/deviceLinkGroups.ts b/apps/api/src/services/deviceLinkGroups.ts
index d4bbb5f8e4..25f560b311 100644
--- a/apps/api/src/services/deviceLinkGroups.ts
+++ b/apps/api/src/services/deviceLinkGroups.ts
@@ -1,13 +1,16 @@
/**
- * Linked device profiles for multi-boot systems (#2138).
+ * Device link groups (#2138 multiboot, #2308 vm_host).
*
* A physical machine that dual/multi-boots runs one Breeze agent per OS and so
* appears as several device records — only one online at a time. A
* `device_link_groups` row plus the `devices.link_group_id` column groups those
- * records as boot profiles of one machine. This is a NON-destructive overlay:
- * device records stay fully separate. Membership is the column on `devices`
- * (one group per device), so there is no child membership table — a group is
- * dissolved by nulling its members and deleting the group row.
+ * records as boot profiles of one machine (kind='multiboot'). kind='vm_host'
+ * (#2308) reuses the same mechanism asymmetrically: one member is the host
+ * server (`devices.link_group_role`='host') and the rest are its guest VMs
+ * ('guest'), all concurrently online. Either way this is a NON-destructive
+ * overlay: device records stay fully separate. Membership is the column on
+ * `devices` (one group per device), so there is no child membership table — a
+ * group is dissolved by nulling its members and deleting the group row.
*
* These helpers run through a `DbExecutor` (the request `db` OR a transaction
* handle) so callers — the link routes and the move-org path — can compose them
@@ -19,6 +22,7 @@
import { eq } from 'drizzle-orm';
import { db } from '../db';
import { deviceLinkGroups, devices } from '../db/schema';
+import { captureException } from './sentry';
/** db instance or an open transaction — both expose the query builders used here. */
export type DbExecutor = typeof db | Parameters[0]>[0];
@@ -29,32 +33,62 @@ export const MIN_LINK_GROUP_SIZE = 2;
/** Upper bound on members in one physical-machine group (generous headroom). */
export const MAX_LINK_GROUP_SIZE = 10;
-/** Clear `link_group_id` on the given devices (unlink), leaving the group row. */
+/**
+ * Clear `link_group_id` on the given devices (unlink), leaving the group row.
+ * Also clears `link_group_role` (#2308) — role is meaningless outside a group,
+ * and a stale 'host'/'guest' left behind would poison the device's NEXT link.
+ */
export async function unlinkDevices(exec: DbExecutor, deviceIds: string[]): Promise {
if (deviceIds.length === 0) return;
for (const id of deviceIds) {
await exec
.update(devices)
- .set({ linkGroupId: null, updatedAt: new Date() })
+ .set({ linkGroupId: null, linkGroupRole: null, updatedAt: new Date() })
.where(eq(devices.id, id));
}
}
/**
- * If a group has fallen below {@link MIN_LINK_GROUP_SIZE} members, unlink any
- * survivor and delete the (now-meaningless) group row. Returns true when the
- * group was dissolved. A group at or above the minimum is left untouched.
+ * Dissolve a group that no longer makes sense: unlink any survivors and delete
+ * the group row. Returns true when the group was dissolved.
+ *
+ * - Any kind: fewer than {@link MIN_LINK_GROUP_SIZE} members.
+ * - vm_host (#2308) additionally: no member with role 'host' remains — guests
+ * without their host server have lost their nesting anchor (the host was
+ * unlinked, moved org, or hard-deleted), so the group is headless and gone.
*/
export async function dissolveLinkGroupIfBelowMinimum(
exec: DbExecutor,
groupId: string,
): Promise {
const members = await exec
- .select({ id: devices.id })
+ .select({ id: devices.id, role: devices.linkGroupRole })
.from(devices)
.where(eq(devices.linkGroupId, groupId));
- if (members.length >= MIN_LINK_GROUP_SIZE) return false;
+ if (members.length >= MIN_LINK_GROUP_SIZE) {
+ const [group] = await exec
+ .select({ kind: deviceLinkGroups.kind })
+ .from(deviceLinkGroups)
+ .where(eq(deviceLinkGroups.id, groupId))
+ .limit(1);
+ if (!group) {
+ // Members reference the group but its row is not visible/present. The
+ // composite FK makes "deleted while still referenced" unreachable, so
+ // this is corruption or an RLS policy filtering the group row while the
+ // device rows stay visible — the same class the /:id/link-group route
+ // reports LOUDLY. Don't invent a dissolve on it, but never swallow it.
+ console.error(
+ `link group ${groupId} has ${members.length} members but no visible group row — possible RLS filtering or data corruption`,
+ );
+ captureException(
+ new Error(`device link group dangling membership: group ${groupId} invisible with ${members.length} members`),
+ );
+ return false;
+ }
+ const headlessVmHost = group.kind === 'vm_host' && !members.some((m) => m.role === 'host');
+ if (!headlessVmHost) return false;
+ }
// Null any lone survivor first — the composite FK forbids deleting the group
// while a device still references it.
diff --git a/apps/web/src/components/devices/DeviceLinkedProfilesTab.test.tsx b/apps/web/src/components/devices/DeviceLinkedProfilesTab.test.tsx
index 9301c3cdd6..794c681c92 100644
--- a/apps/web/src/components/devices/DeviceLinkedProfilesTab.test.tsx
+++ b/apps/web/src/components/devices/DeviceLinkedProfilesTab.test.tsx
@@ -122,4 +122,55 @@ describe('DeviceLinkedProfilesTab', () => {
expect(deleteCall[0]).toBe('/devices/link-groups/g1');
expect(deleteCall[1]).toMatchObject({ method: 'DELETE' });
});
+
+ describe('vm_host groups (#2308)', () => {
+ const vmPayload = {
+ group: { id: 'g-vm', kind: 'vm_host', name: null },
+ members: [
+ { deviceId: 'dev-vm1', hostname: 'vm-web', displayName: null, osType: 'linux', osVersion: '22', agentVersion: '1', status: 'online', lastSeenAt: null, role: 'guest' },
+ { deviceId: 'dev-host', hostname: 'hv-01', displayName: null, osType: 'windows', osVersion: '2022', agentVersion: '1', status: 'online', lastSeenAt: null, role: 'host' },
+ ],
+ };
+
+ it('shows the vm_host heading, guest count, and a Role column with the host sorted first', async () => {
+ fetchWithAuthMock.mockResolvedValue(jsonResponse(vmPayload));
+ render();
+ await waitFor(() => expect(screen.getByTestId('linked-profiles-tab')).toBeInTheDocument());
+
+ expect(screen.getByText('VM host + guests')).toBeInTheDocument();
+ expect(screen.getByText('1 guests')).toBeInTheDocument();
+ expect(screen.getByTestId('linked-profile-dev-host-role')).toHaveTextContent('Host');
+ expect(screen.getByTestId('linked-profile-dev-vm1-role')).toHaveTextContent('Guest');
+ // Host row sorted above the guest despite arriving second from the API.
+ const rows = screen.getAllByTestId(/^linked-profile-dev-(host|vm1)$/);
+ expect(rows[0]!.getAttribute('data-testid')).toBe('linked-profile-dev-host');
+ });
+
+ it('renders no Role column for multiboot groups', async () => {
+ fetchWithAuthMock.mockResolvedValue(
+ jsonResponse({
+ group: { id: 'g1', kind: 'multiboot', name: null },
+ members: [
+ { deviceId: 'dev-1', hostname: 'a', displayName: null, osType: 'windows', osVersion: '11', agentVersion: '1', status: 'online', lastSeenAt: null, role: null },
+ { deviceId: 'dev-2', hostname: 'b', displayName: null, osType: 'linux', osVersion: '22', agentVersion: '1', status: 'offline', lastSeenAt: null, role: null },
+ ],
+ }),
+ );
+ render();
+ await waitFor(() => expect(screen.getByTestId('linked-profiles-tab')).toBeInTheDocument());
+
+ expect(screen.queryByText('Role')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('linked-profile-dev-1-role')).not.toBeInTheDocument();
+ expect(screen.getByText('2 profiles')).toBeInTheDocument();
+ });
+
+ it('warns on the unlink button when the current device is the host', async () => {
+ fetchWithAuthMock.mockResolvedValue(jsonResponse(vmPayload));
+ render();
+ await waitFor(() => expect(screen.getByTestId('linked-profiles-tab')).toBeInTheDocument());
+
+ const unlink = screen.getByTestId('linked-profiles-unlink-self');
+ expect(unlink.getAttribute('title')).toContain('host');
+ });
+ });
});
diff --git a/apps/web/src/components/devices/DeviceLinkedProfilesTab.tsx b/apps/web/src/components/devices/DeviceLinkedProfilesTab.tsx
index d25a12c6eb..c258850442 100644
--- a/apps/web/src/components/devices/DeviceLinkedProfilesTab.tsx
+++ b/apps/web/src/components/devices/DeviceLinkedProfilesTab.tsx
@@ -5,7 +5,7 @@ import { runAction, handleActionError } from "../../lib/runAction";
import { useTranslation } from "react-i18next";
import "../../lib/i18n";
-/** One boot profile (device record) in a linked multi-boot group. */
+/** One member (boot profile, host server, or guest VM) of a link group. */
export interface LinkedProfile {
deviceId: string;
hostname: string;
@@ -15,10 +15,12 @@ export interface LinkedProfile {
agentVersion: string;
status: string;
lastSeenAt: string | null;
+ /** vm_host groups (#2308): 'host' | 'guest'. null for multiboot peers. */
+ role?: string | null;
}
interface LinkGroupResponse {
- group: { id: string; name: string | null } | null;
+ group: { id: string; kind?: string; name: string | null } | null;
members: LinkedProfile[];
}
@@ -81,7 +83,11 @@ export default function DeviceLinkedProfilesTab({
}, [load]);
const group = data?.group ?? null;
- const members = data?.members ?? [];
+ const isVmHost = group?.kind === 'vm_host';
+ // vm_host (#2308): host first, then guests, preserving API order within each.
+ const members = isVmHost
+ ? [...(data?.members ?? [])].sort((a, b) => (a.role === 'host' ? -1 : 0) - (b.role === 'host' ? -1 : 0))
+ : data?.members ?? [];
const unlinkThisDevice = async () => {
if (!group) return;
@@ -184,6 +190,11 @@ export default function DeviceLinkedProfilesTab({
{" "}
{t("deviceLinkedProfilesTab.toGroupThemWhenOnlyOne")}{" "}
+
+ For a virtualization host and its guest VMs, choose
+ Link as VM host + guests instead — the guests nest under
+ the host server's row in the device list while remaining fully managed endpoints.
+
);
}
@@ -194,10 +205,13 @@ export default function DeviceLinkedProfilesTab({
+
+ {/* Only assert a role the data actually carries — a
+ null/unknown role (invariant violation, stale
+ payload) renders a neutral dash, not "Guest". */}
+ {m.role === 'host' ? 'Host' : m.role === 'guest' ? 'Guest' : '—'}
+
+
+ )}
{m.osType}{" "}
diff --git a/apps/web/src/components/devices/DeviceList.test.tsx b/apps/web/src/components/devices/DeviceList.test.tsx
index 5d4678318d..48176b9912 100644
--- a/apps/web/src/components/devices/DeviceList.test.tsx
+++ b/apps/web/src/components/devices/DeviceList.test.tsx
@@ -960,3 +960,102 @@ describe('DeviceList — hidden-decommissioned hint (#2251)', () => {
expect(screen.queryByTestId('decommissioned-hidden-hint')).toBeNull();
});
});
+
+describe('DeviceList — vm_host guest nesting (#2308)', () => {
+ beforeEach(() => {
+ window.localStorage?.clear();
+ });
+
+ const hostId = '71111111-1111-1111-1111-111111111111';
+ const vm1Id = '72222222-2222-2222-2222-222222222222';
+ const vm2Id = '73333333-3333-3333-3333-333333333333';
+
+ const mkVm = (over: Partial): Device => ({
+ ...baseDevice,
+ linkGroupId: 'vm-group-1',
+ status: 'online',
+ ...over,
+ });
+
+ const hostDev = () =>
+ mkVm({ id: hostId, hostname: 'hv-01', linkGroupRole: 'host' });
+ const guest1 = () =>
+ mkVm({ id: vm1Id, hostname: 'vm-web', os: 'linux', linkGroupRole: 'guest' });
+ const guest2 = () =>
+ mkVm({ id: vm2Id, hostname: 'vm-db', os: 'linux', linkGroupRole: 'guest' });
+
+ it('renders guests as full selectable rows nested beneath the host', () => {
+ render();
+
+ // Guests keep their own checkboxes — fully managed rows, not strips.
+ expect(screen.getByLabelText('Select hv-01')).toBeInTheDocument();
+ expect(screen.getByLabelText('Select vm-web')).toBeInTheDocument();
+ expect(screen.getByLabelText('Select vm-db')).toBeInTheDocument();
+ // Nesting affordances: toggle on the host, glyphs on the guests.
+ expect(screen.getByTestId(`device-${hostId}-vm-toggle`)).toBeInTheDocument();
+ expect(screen.getByTestId(`device-${vm1Id}-vm-guest-glyph`)).toBeInTheDocument();
+ expect(screen.getByTestId(`device-${vm2Id}-vm-guest-glyph`)).toBeInTheDocument();
+ // No multiboot treatment leaks in.
+ expect(screen.queryByTestId(`device-${vm1Id}-inactive-strip`)).toBeNull();
+ expect(screen.queryByTestId(`device-${hostId}-group-bar`)).toBeNull();
+ });
+
+ it('collapses guests behind a summary strip and expands them again', () => {
+ render();
+
+ const toggle = screen.getByTestId(`device-${hostId}-vm-toggle`);
+ expect(toggle).toHaveAttribute('aria-expanded', 'true');
+
+ fireEvent.click(toggle);
+ // Guests are hidden; a summary strip stands in.
+ expect(screen.queryByLabelText('Select vm-web')).toBeNull();
+ expect(screen.queryByLabelText('Select vm-db')).toBeNull();
+ const strip = screen.getByTestId(`device-${hostId}-vm-collapsed-strip`);
+ expect(strip.textContent).toContain('2 guest VMs hidden');
+ expect(toggle).toHaveAttribute('aria-expanded', 'false');
+
+ // Clicking the strip expands again.
+ fireEvent.click(strip);
+ expect(screen.getByLabelText('Select vm-web')).toBeInTheDocument();
+ expect(screen.queryByTestId(`device-${hostId}-vm-collapsed-strip`)).toBeNull();
+ });
+
+ it('excludes collapsed (hidden) guests from select-all', () => {
+ const onBulkAction = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByTestId(`device-${hostId}-vm-toggle`));
+ fireEvent.click(screen.getByLabelText('Select all devices on this page'));
+ fireEvent.click(screen.getByRole('button', { name: /bulk actions/i }));
+ fireEvent.click(screen.getByText('Reboot Selected'));
+
+ expect(onBulkAction).toHaveBeenCalledTimes(1);
+ const selected = onBulkAction.mock.calls[0]![1] as Device[];
+ expect(selected.map((d) => d.id)).toEqual([hostId]);
+ });
+
+ it('offers the "Link as VM host + guests" bulk action when 2+ devices are selected', () => {
+ const onBulkAction = vi.fn();
+ const plain = { ...baseDevice, id: '74444444-4444-4444-4444-444444444444', hostname: 'plain-b' };
+ render();
+
+ fireEvent.click(screen.getByLabelText('Select all devices on this page'));
+ fireEvent.click(screen.getByRole('button', { name: /bulk actions/i }));
+ fireEvent.click(screen.getByTestId('bulk-link-vm-host'));
+
+ expect(onBulkAction).toHaveBeenCalledTimes(1);
+ expect(onBulkAction.mock.calls[0]![0]).toBe('link-vm-host');
+ expect((onBulkAction.mock.calls[0]![1] as Device[]).map((d) => d.id).sort()).toEqual(
+ [baseDevice.id, plain.id].sort(),
+ );
+ });
+
+ it('renders guests as plain ungrouped rows when the host is not on the page', () => {
+ render();
+
+ expect(screen.queryByTestId(`device-${vm1Id}-vm-guest-glyph`)).toBeNull();
+ expect(screen.queryByTestId(`device-${vm2Id}-vm-guest-glyph`)).toBeNull();
+ expect(screen.getByLabelText('Select vm-web')).toBeInTheDocument();
+ expect(screen.getByLabelText('Select vm-db')).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/components/devices/DeviceList.tsx b/apps/web/src/components/devices/DeviceList.tsx
index 0d7e8a1b23..0f8655e3e6 100644
--- a/apps/web/src/components/devices/DeviceList.tsx
+++ b/apps/web/src/components/devices/DeviceList.tsx
@@ -6,6 +6,7 @@ import {
ChevronUp,
ChevronDown,
ArrowUpDown,
+ CornerDownRight,
MoreHorizontal,
MoreVertical,
Filter,
@@ -201,6 +202,13 @@ export type Device = {
* groupLinkedDevices in linkedDevices.ts.
*/
linkGroupId?: string | null;
+ /**
+ * vm_host link groups (#2308). 'host' = this record is the host server of a
+ * vm_host group; 'guest' = a guest VM nested under that host. null/undefined
+ * for unlinked devices and multiboot members (peers). A non-null role
+ * implies the group's kind is 'vm_host' — no group fetch needed.
+ */
+ linkGroupRole?: 'host' | 'guest' | null;
};
// Columns that only make sense for the network arm (#1322); hidden unless
@@ -510,6 +518,10 @@ export default function DeviceList({
);
useEffect(() => subscribeLinkedProfileCollapse(setLinkedCollapse), []);
const [selectedIds, setSelectedIds] = useState>(new Set());
+ // vm_host nesting (#2308): link-group ids whose guest rows are collapsed
+ // beneath their host. Transient per-visit presentation state (default:
+ // expanded, guests visible) — deliberately NOT persisted/hash-encoded.
+ const [collapsedVmGroups, setCollapsedVmGroups] = useState>(new Set());
const [bulkMenuOpen, setBulkMenuOpen] = useState(false);
const [rowMenuOpenId, setRowMenuOpenId] = useState(null);
// Flip the row dropdown direction when the click happens close to the
@@ -798,22 +810,35 @@ export default function DeviceList({
startIndex + effectivePageSize,
);
- // Linked multi-boot presentation (#2138): computed client-side WITHIN the
- // current page. Exactly one online member → offline siblings render as thin
- // strips beneath it; all offline → full rows with a left-edge group bar;
- // 2+ online → all normal rows. Toggle off → flat list.
+ // Linked-device presentation, computed client-side WITHIN the current page.
+ // Multiboot (#2138): exactly one online member → offline siblings render as
+ // thin strips beneath it; all offline → full rows with a left-edge group
+ // bar; 2+ online → all normal rows. The collapse toggle gates ONLY these
+ // multiboot heuristics (off → flat multiboot rows). vm_host nesting (#2308)
+ // is hierarchical organization, not offline-noise suppression, so guests
+ // nest under their host regardless of the toggle.
const displayRows = useMemo(
() => groupLinkedDevices(paginatedDevices, linkedCollapse === "on"),
[paginatedDevices, linkedCollapse],
);
+ // vm_host nesting (#2308): drop guest rows whose group is collapsed. The
+ // host row renders a "N guests hidden" strip in their place.
+ const visibleRows = useMemo(
+ () => displayRows.filter(r => !(r.vmRole === 'guest' && r.vmGroupId && collapsedVmGroups.has(r.vmGroupId))),
+ [displayRows, collapsedVmGroups]
+ );
// Strips are NOT selectable rows — bulk selection only sees real full rows.
+ // Collapsed (hidden) vm_host guests are likewise excluded: select-all must
+ // never silently pick up rows the user cannot see.
const selectablePageDevices = useMemo(
- () => displayRows.map((r) => r.device),
- [displayRows],
+ () => visibleRows.map((r) => r.device),
+ [visibleRows],
);
- // Only offer the toggle when the fleet actually has linked profiles.
+ // Only offer the collapse toggle when the fleet actually has MULTIBOOT
+ // linked profiles — it doesn't govern vm_host nesting (#2308), so a fleet
+ // with only vm_host groups gets no dead toggle.
const hasLinkedDevices = useMemo(
- () => devices.some((d) => d.linkGroupId),
+ () => devices.some((d) => d.linkGroupId && !d.linkGroupRole),
[devices],
);
@@ -1953,6 +1978,16 @@ export default function DeviceList({
{t("deviceList.linkAsMultiBoot")}{" "}
)}
+ {selectedIds.size >= 2 && (
+
+ )}
+ {/* vm_host (#2308): when a host's guests are collapsed, a thin
+ strip stands in for them — click to expand. */}
+ {vmRole === "host" &&
+ vmGroupId &&
+ collapsedVmGroups.has(vmGroupId) && (
+