fix(rooms): fleet room by shown name, box named by pinned card (#458)

POST /box matched the fleet model by exact string, but nesktop sends the
name it shows (vendor stripped: "Radeon RX 9060 XT" for the host's "AMD
Radeon RX 9060 XT"), so every fleet room was refused with "No ... is
free right now". Models now match with or without the vendor in
placement and in the checkout's gpu.

A listed box's `gpu` was its host's first card; it is now the card the
box is pinned to (slot, then PCI id, then first card).

Tests: two new cases in apps/api/test/rooms.test.ts fail without the
change; rooms, fleet and billing-checkout-gpu suites pass (40) against
the local test Postgres.
This commit is contained in:
Wanjohi
2026-10-19 21:46:48 +00:00
committed by GitHub
6 changed files with 58 additions and 5 deletions
+18
View File
@@ -166,6 +166,24 @@ describe('POST /box on a chosen GPU', () => {
expect(box.gpuPciId).toBeNull();
});
test('a fleet room is found by the name a client shows, without the vendor', async () => {
await fleet([[V710]]);
const p = await person('fleet-short-name', 'paid');
const res = await post(p.headers, { on: 'fleet', model: 'Radeon Pro V710' });
expect(res.status).toBe(201);
expect((await json(res)).data.gpuPciId).toBe(V710.pciId);
});
test('a box is named by the card it is pinned to, not its host\'s first card', async () => {
const p = await person('pinned-name');
const host = await Fixtures.machine(p.owner, 'two-cards');
await Machine.setGpus({ id: host, gpus: [BIG, V710] });
const res = await post(p.headers, { on: 'own', machineId: host, pciId: V710.pciId });
expect(res.status).toBe(201);
const listed = (await json(await app.request('/box', { headers: p.headers }))).data;
expect(listed.find((b: { machineId: string }) => b.machineId === host).gpu).toBe(V710.model);
});
test('a fleet room is placed by model on the least-loaded machine with one free', async () => {
const f = await fleet([[V710], [V710]]);
const a = await person('fleet-model-a');
+4 -3
View File
@@ -32,7 +32,7 @@ export namespace CheckoutRoom {
/** Refuse a model the fleet does not have. */
export async function assertKnown(model: string): Promise<void> {
if (!(await knownModels()).includes(model)) {
if (!(await knownModels()).some((m) => Rooms.sameModel(m, model))) {
throw new VisibleError(
'validation',
ErrorCodes.Validation.INVALID_PARAMETER,
@@ -54,9 +54,10 @@ export namespace CheckoutRoom {
cards: readonly Placement.Card[]
): { model: string; substituted: boolean } | null {
const free = cards.filter((c) => c.free);
if (free.some((c) => c.model === requested)) return { model: requested, substituted: false };
const exact = free.find((c) => Rooms.sameModel(c.model, requested));
if (exact) return { model: exact.model, substituted: false };
if (free.length === 0) return null;
const wanted = cards.find((c) => c.model === requested);
const wanted = cards.find((c) => Rooms.sameModel(c.model, requested));
const wantedVendor = wanted ? vendor(wanted.pciId) : '';
const wantedVram = wanted?.vramMib ?? 0;
const rank = (c: Placement.Card) => [
+18
View File
@@ -32,6 +32,24 @@ export namespace Rooms {
return entryModels().some((m) => name.includes(m.toLowerCase()));
}
/**
* A card's name without the vendor: clients show `RTX 3060` and
* `Radeon RX 9060 XT`, hosts report `NVIDIA GeForce RTX 3060` and
* `AMD Radeon RX 9060 XT`.
*/
export function bareModel(model: string): string {
let m = model.trim();
for (const prefix of ['NVIDIA ', 'GeForce ', 'AMD ', 'Intel(R) ', 'Intel ']) {
if (m.toLowerCase().startsWith(prefix.toLowerCase())) m = m.slice(prefix.length);
}
return m.toLowerCase();
}
/** Whether two names are one card, with or without the vendor. */
export function sameModel(a: string, b: string): boolean {
return bareModel(a) === bareModel(b);
}
export function isPro(plan: string | null | undefined): boolean {
return plan === 'paid';
}
+1 -1
View File
@@ -177,7 +177,7 @@ export namespace Box {
.orderBy(BoxTable.timeCreated)
.then((rows) =>
rows.map((r) =>
serialize(r.box, Machine.gpuName(r.gpus), {
serialize(r.box, Machine.pinnedName(r.gpus, r.box), {
host: r.host,
fleet: r.organisationId !== null
})
+3 -1
View File
@@ -338,7 +338,9 @@ export namespace Placement {
export const onFleetModel =
(model: string): Placer =>
async () => {
const free = (await lockedFreeCards(fleetOrganisation())).filter((c) => c.model === model);
const free = (await lockedFreeCards(fleetOrganisation())).filter((c) =>
Rooms.sameModel(c.model, model)
);
if (free.length === 0) {
throw new VisibleError(
'rate_limit',
+14
View File
@@ -85,6 +85,20 @@ export namespace Machine {
return gpus?.[0]?.model ?? null;
}
/**
* The card a box is pinned to, by its slot, else its PCI id; the host's
* first card for a box that names none.
*/
export function pinnedName(
gpus: readonly { model: string; pciId: string; slot?: string }[] | null | undefined,
pin: { gpuPciId?: string | null; gpuSlot?: string | null }
): string | null {
const card =
(pin.gpuSlot ? gpus?.find((g) => g.slot === pin.gpuSlot) : undefined) ??
(pin.gpuPciId ? gpus?.find((g) => g.pciId === pin.gpuPciId) : undefined);
return card?.model ?? gpuName(gpus);
}
export const Info = z
.object({
id: z.string().meta({