feat(session): a run picks its own size

POST /session takes an optional tier. The host starts the run at that
size, the allowance is checked against it, and the run is billed at it.
A run asked for without one runs at its box's size, as before.

The size belongs to the run rather than the box: a box is a game's
install on a machine, and the size is chosen each time it is played, so
playing bigger never means a second box and a second install.
This commit is contained in:
Wanjohi
2026-10-10 14:03:13 +03:00
parent 05a3fee87a
commit 8608506682
6 changed files with 50 additions and 6 deletions
@@ -162,6 +162,13 @@
"when": 1791537816909,
"tag": "0022_session_stop_requested",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1791629955619,
"tag": "0023_session_tier",
"breakpoints": true
}
]
}
+11 -4
View File
@@ -59,6 +59,10 @@ export namespace Session {
'Current connect ticket. Null before one has been minted, and null again once the run has stopped — a run that is not there has no address',
example: Examples.Session.ticket
}),
tier: z.enum(BoxTier.enumValues).nullable().optional().meta({
description: 'The size this run was asked for. Null runs at the size of its box',
example: 'md'
}),
timeStarted: z.string().nullable().optional().meta({
description: 'When the box actually started, not when the row appeared',
example: Examples.Session.timeStarted
@@ -91,7 +95,7 @@ export namespace Session {
export type Info = z.infer<typeof Info>;
export const create = fn(
Info.pick({ id: true, boxId: true, gameId: true, linkedAccountId: true }).extend({
Info.pick({ id: true, boxId: true, gameId: true, linkedAccountId: true, tier: true }).extend({
trial: z.boolean().optional()
}),
async (input) => {
@@ -103,6 +107,7 @@ export namespace Session {
boxId: input.boxId,
gameId: input.gameId,
linkedAccountId: input.linkedAccountId,
tier: input.tier ?? null,
trial: input.trial ?? false
})
.returning()
@@ -133,7 +138,7 @@ export namespace Session {
* answered is a timing detail.
*/
export const request = fn(
Info.pick({ id: true, boxId: true, gameId: true, linkedAccountId: true }).extend({
Info.pick({ id: true, boxId: true, gameId: true, linkedAccountId: true, tier: true }).extend({
trial: z.boolean().optional()
}),
async (input) => {
@@ -442,7 +447,8 @@ export namespace Session {
kind: 'session.start',
sessionId: row.session.id,
boxId: row.box.id,
boxTier: row.box.tier as z.infer<typeof StartJob>['boxTier'],
// The run's own size when it was given one.
boxTier: (row.session.tier ?? row.box.tier) as z.infer<typeof StartJob>['boxTier'],
gameId: row.game.id,
steamAppId: row.game.steamAppId,
linkedAccountId: row.session.linkedAccountId,
@@ -825,7 +831,7 @@ export namespace Session {
await Burn.start({
teamId: payer.teamId,
sessionId: moved.id,
tier: payer.tier,
tier: moved.tier ?? payer.tier,
hostClass: payer.hostClass
});
} else if (
@@ -937,6 +943,7 @@ export namespace Session {
gameId: input.gameId,
linkedAccountId: input.linkedAccountId,
state: input.state as Info['state'],
tier: input.tier as Info['tier'],
ticket: input.ticket,
timeStarted: input.timeStarted?.toISOString() ?? null,
timeStopped: input.timeStopped?.toISOString() ?? null,
+6 -1
View File
@@ -1,7 +1,7 @@
import { sql } from 'drizzle-orm';
import { boolean, index, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { BoxTable } from '../box/box.sql.js';
import { BoxTable, BoxTier } from '../box/box.sql.js';
import { id, timestamps, ulid, utc } from '../db/types.js';
import { GameTable } from '../game/game.sql.js';
import { LinkedAccountTable } from '../user/linked-account.sql.js';
@@ -53,6 +53,11 @@ export const SessionTable = pgTable(
.notNull()
.references(() => LinkedAccountTable.id, { onDelete: 'restrict' }),
state: SessionState('state').notNull().default('requested'),
// The size of this run, picked when it is asked for. Null for a run
// asked for without one, which runs at its box's size. On the run and
// not the box, because the box is a game's install on a machine and
// a size is chosen each time it is played.
tier: BoxTier('tier'),
/**
* The current iroh connect ticket, or null before `neshub` has minted
* one. Rewritten as addresses are discovered; never append-only.
+16
View File
@@ -250,6 +250,22 @@ describe('Session jobs', () => {
expect(jobs[0]!.linkedAccountId).toBe(owner.linkedAccountId);
});
test('a run asked for at a size starts at that size, not its box’s', async () => {
const { owner, machineId, box, gameId } = await scene('ses-job-tier', 5411);
const session = await Session.create({
id: Identifier.ascending('session'),
boxId: box.id,
gameId,
linkedAccountId: owner.linkedAccountId,
tier: 'lg'
});
expect(session.tier).toBe('lg');
const jobs = await Session.listJobsForMachine(machineId);
expect(jobs[0]!.kind).toBe('session.start');
expect(jobs[0]!.boxTier).toBe('lg');
});
test('a job belongs to the machine its box is placed on and to no other', async () => {
const mine = await scene('ses-job-mine', 5411);
const theirs = await scene('ses-job-theirs', 5412);