mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 14:12:39 +02:00
feat(trial): a free weekend on rented GPUs, for teams without a plan
A team with no plan may run on the fleet from Friday 00:00 to Monday 00:00, Helsinki time, for two hours of live play per weekend. One trial per team, per email address and per Steam account; the claim outlives the account, so closing it and signing up again is not a second trial. Once five teams pay, the trial closes for everyone. A trial run is marked on the session. Its time is counted from the run's own live interval and kept out of the plan's allowance. Unlike an allowance, the trial ends a run: the host's work poll now carries a session.stop job for any trial run past its hours or its window, which an agent that does not know the kind already skips.
This commit is contained in:
@@ -6,6 +6,8 @@ import { Examples } from '@nestri/core/examples';
|
||||
import { Game } from '@nestri/core/game/index';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Session } from '@nestri/core/session/index';
|
||||
import { Team } from '@nestri/core/team/index';
|
||||
import { Trial } from '@nestri/core/trial/index';
|
||||
import { Library } from '@nestri/core/user/library';
|
||||
import { LinkedAccount } from '@nestri/core/user/linked-account';
|
||||
import { Hono } from 'hono';
|
||||
@@ -236,20 +238,31 @@ export namespace SessionApi {
|
||||
// being discarded, because the caller has to be told what it will
|
||||
// cost and what remains — and asking a second time would let the
|
||||
// number shown and the number billed disagree.
|
||||
//
|
||||
// On rented GPUs without a plan, the free weekend decides instead,
|
||||
// and the run is marked as a trial so it is counted, and stopped,
|
||||
// by the trial's rules rather than the allowance's.
|
||||
const payer = await Billing.teamForBox(box.id);
|
||||
const billing = payer
|
||||
? await Billing.assertMayStart({
|
||||
teamId: payer.teamId,
|
||||
nextTier: payer.tier,
|
||||
nextHostClass: payer.hostClass
|
||||
})
|
||||
: null;
|
||||
const trial =
|
||||
payer?.hostClass === 'fleet' && (await Team.fromID(payer.teamId))?.plan !== 'paid';
|
||||
if (trial) {
|
||||
await Trial.assertMayStart({ teamId: payer.teamId, userId, linkedAccountId });
|
||||
}
|
||||
const billing =
|
||||
payer && !trial
|
||||
? await Billing.assertMayStart({
|
||||
teamId: payer.teamId,
|
||||
nextTier: payer.tier,
|
||||
nextHostClass: payer.hostClass
|
||||
})
|
||||
: null;
|
||||
|
||||
const session = await Session.request({
|
||||
id: Identifier.ascending('session'),
|
||||
boxId: box.id,
|
||||
gameId: game.id,
|
||||
linkedAccountId
|
||||
linkedAccountId,
|
||||
trial
|
||||
});
|
||||
return c.json({ data: session, billing }, 201);
|
||||
}
|
||||
@@ -410,11 +423,11 @@ export namespace SessionApi {
|
||||
tags: ['Session'],
|
||||
summary: 'Ask for work',
|
||||
description:
|
||||
'Returns the runs waiting to be started on the calling host, and only those — the host comes from its own credentials and the scope is the query, so a box cannot see work for another. Poll at the cadence the heartbeat hands down. Each job carries its kind, so a second kind of work is an addition rather than a change of shape.',
|
||||
'Returns the work for the calling host, and only that host — it comes from its own credentials and the scope is the query, so a box cannot see work for another. `session.start` is a run waiting to be started; `session.stop` is a run to end now because the terms it started under ran out, repeated on every poll until the host reports it ended. Poll at the cadence the heartbeat hands down. An agent skips a kind it does not know.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(z.array(Session.Job)) } },
|
||||
description: 'Work waiting for this host, oldest first'
|
||||
description: 'Work for this host: runs to end first, then runs to start, oldest first'
|
||||
},
|
||||
403: ErrorResponses[403]
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ describe('POST /session', () => {
|
||||
ticket: null,
|
||||
timeStarted: null,
|
||||
timeStopped: null,
|
||||
trial: false,
|
||||
errorMessage: null
|
||||
});
|
||||
expect(body.data.id.startsWith('ses_')).toBe(true);
|
||||
@@ -724,7 +725,8 @@ describe('POST /session/:id/ticket', () => {
|
||||
'state',
|
||||
'ticket',
|
||||
'timeStarted',
|
||||
'timeStopped'
|
||||
'timeStopped',
|
||||
'trial'
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { AccessToken } from '@nestri/core/access-token/index';
|
||||
import { Fixtures } from '@nestri/core/db/fixtures';
|
||||
import { testDb } from '@nestri/core/db/test';
|
||||
import { Env } from '@nestri/core/env';
|
||||
import { Game } from '@nestri/core/game/index';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Trial } from '@nestri/core/trial/index';
|
||||
import { Library } from '@nestri/core/user/library';
|
||||
|
||||
import { app } from '../app/index';
|
||||
import { TEST_FRONTEND_URL } from './setup';
|
||||
|
||||
const sql = testDb();
|
||||
const createdUserIds: string[] = [];
|
||||
const createdOrgIds: string[] = [];
|
||||
const createdGameIds: string[] = [];
|
||||
|
||||
/** Times on the clock the trial keeps, written out as instants. */
|
||||
const WEDNESDAY = new Date('2026-10-07T12:00:00Z');
|
||||
const SATURDAY = new Date('2026-10-10T12:00:00Z');
|
||||
|
||||
function at(instant: Date) {
|
||||
Trial.useClock(() => instant);
|
||||
}
|
||||
|
||||
afterEach(() => Trial.useClock(() => new Date()));
|
||||
|
||||
afterAll(async () => {
|
||||
await sql`delete from trial_claim where user_id = any(${createdUserIds}) or email like '%@trial.example.test'`;
|
||||
await sql`delete from burn_segment where session_id in (select s.id from session s join box b on b.id = s.box_id where b.user_id = any(${createdUserIds}))`;
|
||||
await sql`delete from session where box_id in (select id from box where user_id = any(${createdUserIds}))`;
|
||||
await sql`delete from box where user_id = any(${createdUserIds})`;
|
||||
await sql`delete from machine where owner_user_id = any(${createdUserIds})`;
|
||||
await sql`delete from organisation where id = any(${createdOrgIds})`;
|
||||
await sql`delete from game where id = any(${createdGameIds})`;
|
||||
await sql`delete from "user" where id = any(${createdUserIds})`;
|
||||
});
|
||||
|
||||
/** A fleet of one, a person with a box on it and a game to play, and both credentials. */
|
||||
async function scene(label: string) {
|
||||
const operator = await Fixtures.owner(`${label}-op`);
|
||||
createdUserIds.push(operator.userId);
|
||||
const fleet = await Fixtures.fleet(operator, 1);
|
||||
createdOrgIds.push(fleet.organisationId);
|
||||
Env.init({
|
||||
NODE_ENV: 'test',
|
||||
FRONTEND_URL: TEST_FRONTEND_URL,
|
||||
FLEET_ORGANISATION_ID: fleet.organisationId
|
||||
});
|
||||
|
||||
const owner = await Fixtures.owner(label);
|
||||
createdUserIds.push(owner.userId);
|
||||
const pat = await AccessToken.create({
|
||||
id: Identifier.ascending('accessToken'),
|
||||
ownerUserId: owner.userId,
|
||||
teamId: null,
|
||||
name: label
|
||||
});
|
||||
const user = { authorization: `Bearer ${pat.token}`, 'content-type': 'application/json' };
|
||||
|
||||
const boxRes = await app.request('/box', {
|
||||
method: 'POST',
|
||||
headers: user,
|
||||
body: JSON.stringify({ on: 'fleet' })
|
||||
});
|
||||
const box = ((await boxRes.json()) as any).data;
|
||||
|
||||
const steamAppId = 980000 + createdGameIds.length;
|
||||
const [game] = await Game.upsert({
|
||||
id: Identifier.ascending('game'),
|
||||
steamAppId,
|
||||
slug: `trial-${steamAppId}`,
|
||||
name: `Trial ${steamAppId}`
|
||||
});
|
||||
createdGameIds.push(game!.id);
|
||||
await Library.upsert({
|
||||
id: Identifier.ascending('userLibrary'),
|
||||
userId: owner.userId,
|
||||
gameId: game!.id,
|
||||
playtime2w: null,
|
||||
playtimeForever: null,
|
||||
lastPlayed: null
|
||||
});
|
||||
|
||||
const machine = fleet.machines[0]!;
|
||||
return {
|
||||
owner,
|
||||
box,
|
||||
gameId: game!.id,
|
||||
user,
|
||||
host: {
|
||||
'x-nestri-machine-id': machine.id,
|
||||
'x-nestri-machine-secret': machine.secret
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function play(s: Awaited<ReturnType<typeof scene>>) {
|
||||
const res = await app.request('/session', {
|
||||
method: 'POST',
|
||||
headers: s.user,
|
||||
body: JSON.stringify({
|
||||
boxId: s.box.id,
|
||||
gameId: s.gameId,
|
||||
linkedAccountId: s.owner.linkedAccountId
|
||||
})
|
||||
});
|
||||
return { res, body: (await res.json()) as any };
|
||||
}
|
||||
|
||||
/** A past run of this box, live from `start` for `minutes`, as the trial counts it. */
|
||||
async function playedBefore(
|
||||
boxId: string,
|
||||
gameId: string,
|
||||
linkedAccountId: string,
|
||||
start: Date,
|
||||
minutes: number
|
||||
) {
|
||||
const id = Identifier.ascending('session');
|
||||
const stop = new Date(start.getTime() + minutes * 60_000);
|
||||
await sql`insert into session (id, box_id, game_id, linked_account_id, state, trial, time_started, time_stopped)
|
||||
values (${id}, ${boxId}, ${gameId}, ${linkedAccountId}, 'ended', true, ${start}, ${stop})`;
|
||||
return id;
|
||||
}
|
||||
|
||||
describe('The weekend window', () => {
|
||||
test('Friday 00:00 to Monday 00:00 on the trial’s clock', () => {
|
||||
const closed = Trial.window(WEDNESDAY);
|
||||
expect(closed.open).toBe(false);
|
||||
// Summer time: midnight there is 21:00 UTC the day before.
|
||||
expect(closed.start.toISOString()).toBe('2026-10-08T21:00:00.000Z');
|
||||
expect(closed.end.toISOString()).toBe('2026-10-11T21:00:00.000Z');
|
||||
|
||||
const open = Trial.window(SATURDAY);
|
||||
expect(open.open).toBe(true);
|
||||
expect(open.start.toISOString()).toBe(closed.start.toISOString());
|
||||
});
|
||||
|
||||
test('a weekend with a clock change in it is still three calendar days', () => {
|
||||
// Summer time ends in the early hours of Sunday 25 October 2026.
|
||||
const w = Trial.window(new Date('2026-10-24T12:00:00Z'));
|
||||
expect(w.start.toISOString()).toBe('2026-10-22T21:00:00.000Z');
|
||||
expect(w.end.toISOString()).toBe('2026-10-25T22:00:00.000Z');
|
||||
});
|
||||
|
||||
test('the last minute of Sunday is in; Monday 00:00 is out', () => {
|
||||
expect(Trial.window(new Date('2026-10-11T20:59:00Z')).open).toBe(true);
|
||||
expect(Trial.window(new Date('2026-10-11T21:00:00Z')).open).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session on rented GPUs without a plan', () => {
|
||||
test('in the window: a trial run, and the claim is made', async () => {
|
||||
at(SATURDAY);
|
||||
const s = await scene('trial-ok');
|
||||
const { res, body } = await play(s);
|
||||
expect(res.status).toBe(201);
|
||||
expect(body.data.trial).toBe(true);
|
||||
expect(body.billing).toBeNull();
|
||||
const claims =
|
||||
await sql`select email, steam_id from trial_claim where team_id = ${s.owner.teamId}`;
|
||||
expect(claims.length).toBe(1);
|
||||
});
|
||||
|
||||
test('outside the window: 429, naming when it opens', async () => {
|
||||
at(WEDNESDAY);
|
||||
const s = await scene('trial-closed');
|
||||
const { res, body } = await play(s);
|
||||
expect(res.status).toBe(429);
|
||||
expect(body.message).toContain('Friday to Sunday');
|
||||
});
|
||||
|
||||
test('two hours used this weekend: 429', async () => {
|
||||
at(SATURDAY);
|
||||
const s = await scene('trial-spent');
|
||||
await playedBefore(
|
||||
s.box.id,
|
||||
s.gameId,
|
||||
s.owner.linkedAccountId,
|
||||
new Date('2026-10-09T10:00:00Z'),
|
||||
120
|
||||
);
|
||||
const { res, body } = await play(s);
|
||||
expect(res.status).toBe(429);
|
||||
expect(body.message).toContain('2 free hours');
|
||||
});
|
||||
|
||||
test('time from last weekend does not count', async () => {
|
||||
at(SATURDAY);
|
||||
const s = await scene('trial-last-week');
|
||||
await playedBefore(
|
||||
s.box.id,
|
||||
s.gameId,
|
||||
s.owner.linkedAccountId,
|
||||
new Date('2026-10-03T10:00:00Z'),
|
||||
180
|
||||
);
|
||||
expect((await play(s)).res.status).toBe(201);
|
||||
});
|
||||
|
||||
test('an address that already had the trial: 403', async () => {
|
||||
at(SATURDAY);
|
||||
const s = await scene('trial-email');
|
||||
const [person] = await sql`select email from "user" where id = ${s.owner.userId}`;
|
||||
await sql`insert into trial_claim (id, email, steam_id)
|
||||
values (${Identifier.ascending('trialClaim')}, ${person!.email}, ${'old-' + s.owner.userId})`;
|
||||
const { res, body } = await play(s);
|
||||
expect(res.status).toBe(403);
|
||||
expect(body.message).toContain('already had the free weekend');
|
||||
});
|
||||
|
||||
test('a Steam account that already had the trial: 403', async () => {
|
||||
at(SATURDAY);
|
||||
const s = await scene('trial-steam');
|
||||
const [linked] =
|
||||
await sql`select provider_account_id from linked_account where id = ${s.owner.linkedAccountId}`;
|
||||
await sql`insert into trial_claim (id, email, steam_id)
|
||||
values (${Identifier.ascending('trialClaim')}, ${'gone@trial.example.test'}, ${linked!.provider_account_id})`;
|
||||
expect((await play(s)).res.status).toBe(403);
|
||||
});
|
||||
|
||||
test('five paying teams close it for everyone', async () => {
|
||||
at(SATURDAY);
|
||||
const s = await scene('trial-full');
|
||||
const payers: string[] = [];
|
||||
for (let i = 0; i < Trial.PAID_SEATS; i++) {
|
||||
const p = await Fixtures.owner(`payer-${i}`);
|
||||
createdUserIds.push(p.userId);
|
||||
payers.push(p.teamId);
|
||||
}
|
||||
await sql`update team set plan = 'paid' where id = any(${payers})`;
|
||||
try {
|
||||
const { res, body } = await play(s);
|
||||
expect(res.status).toBe(403);
|
||||
expect(body.message).toContain('has closed');
|
||||
} finally {
|
||||
await sql`update team set plan = 'free' where id = any(${payers})`;
|
||||
}
|
||||
});
|
||||
|
||||
test('a team with a plan is not on the trial', async () => {
|
||||
at(WEDNESDAY);
|
||||
const s = await scene('trial-paid');
|
||||
await sql`update team set plan = 'paid' where id = ${s.owner.teamId}`;
|
||||
try {
|
||||
const { res, body } = await play(s);
|
||||
expect(res.status).toBe(201);
|
||||
expect(body.data.trial).toBe(false);
|
||||
expect(body.billing).not.toBeNull();
|
||||
} finally {
|
||||
await sql`update team set plan = 'free' where id = ${s.owner.teamId}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stopping a trial run', () => {
|
||||
async function liveTrialRun(s: Awaited<ReturnType<typeof scene>>, startedAt: Date) {
|
||||
const id = Identifier.ascending('session');
|
||||
await sql`insert into session (id, box_id, game_id, linked_account_id, state, trial, time_started)
|
||||
values (${id}, ${s.box.id}, ${s.gameId}, ${s.owner.linkedAccountId}, 'live', true, ${startedAt})`;
|
||||
return id;
|
||||
}
|
||||
|
||||
async function jobs(s: Awaited<ReturnType<typeof scene>>) {
|
||||
const res = await app.request('/machine/jobs', { headers: s.host });
|
||||
return ((await res.json()) as any).data as any[];
|
||||
}
|
||||
|
||||
test('nothing to stop while the hours last', async () => {
|
||||
at(SATURDAY);
|
||||
const s = await scene('stop-none');
|
||||
await liveTrialRun(s, new Date(SATURDAY.getTime() - 60 * 60_000));
|
||||
expect((await jobs(s)).filter((j) => j.kind === 'session.stop')).toEqual([]);
|
||||
});
|
||||
|
||||
test('past two hours: the host is told to stop it, for hours', async () => {
|
||||
at(SATURDAY);
|
||||
const s = await scene('stop-hours');
|
||||
const id = await liveTrialRun(s, new Date(SATURDAY.getTime() - 121 * 60_000));
|
||||
const stops = (await jobs(s)).filter((j) => j.kind === 'session.stop');
|
||||
expect(stops).toEqual([
|
||||
{ kind: 'session.stop', sessionId: id, boxId: s.box.id, reason: 'hours' }
|
||||
]);
|
||||
});
|
||||
|
||||
test('when the window closes: stopped, for the window', async () => {
|
||||
at(new Date('2026-10-11T21:05:00Z'));
|
||||
const s = await scene('stop-window');
|
||||
const id = await liveTrialRun(s, new Date('2026-10-11T20:30:00Z'));
|
||||
const stops = (await jobs(s)).filter((j) => j.kind === 'session.stop');
|
||||
expect(stops).toEqual([
|
||||
{ kind: 'session.stop', sessionId: id, boxId: s.box.id, reason: 'window' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE "trial_claim" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"team_id" char(30),
|
||||
"user_id" char(30),
|
||||
"email" text NOT NULL,
|
||||
"steam_id" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "session" ADD COLUMN "trial" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "trial_claim" ADD CONSTRAINT "trial_claim_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "trial_claim" ADD CONSTRAINT "trial_claim_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "trial_claim_email_unique" ON "trial_claim" USING btree ("email");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "trial_claim_steam_unique" ON "trial_claim" USING btree ("steam_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "trial_claim_team_unique" ON "trial_claim" USING btree ("team_id");--> statement-breakpoint
|
||||
CREATE INDEX "trial_claim_user_idx" ON "trial_claim" USING btree ("user_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,13 @@
|
||||
"when": 1790573670492,
|
||||
"tag": "0017_install_token",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1791354383696,
|
||||
"tag": "0018_free_weekend_trial",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export namespace Identifier {
|
||||
pairingCode: 'pai',
|
||||
machine: 'mch',
|
||||
installToken: 'mit',
|
||||
trialClaim: 'trc',
|
||||
box: 'box',
|
||||
session: 'ses',
|
||||
accessToken: 'pat',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ErrorCodes, VisibleError } from '../error.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { GameTable } from '../game/game.sql.js';
|
||||
import { Trial } from '../trial/index.js';
|
||||
import { SessionState, SessionTable } from './session.sql.js';
|
||||
|
||||
/**
|
||||
@@ -66,6 +67,11 @@ export namespace Session {
|
||||
description: 'When this run ended',
|
||||
example: Examples.Session.timeStopped
|
||||
}),
|
||||
trial: z.boolean().optional().meta({
|
||||
description:
|
||||
'Whether this run is on Nestri GPUs under the free weekend rather than a plan. A trial run is stopped when its hours or its window run out',
|
||||
example: false
|
||||
}),
|
||||
errorMessage: z.string().nullable().optional().meta({
|
||||
description: 'Why it failed, when it did',
|
||||
example: Examples.Session.errorMessage
|
||||
@@ -80,7 +86,9 @@ export namespace Session {
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(
|
||||
Info.pick({ id: true, boxId: true, gameId: true, linkedAccountId: true }),
|
||||
Info.pick({ id: true, boxId: true, gameId: true, linkedAccountId: true }).extend({
|
||||
trial: z.boolean().optional()
|
||||
}),
|
||||
async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
@@ -89,7 +97,8 @@ export namespace Session {
|
||||
id: input.id,
|
||||
boxId: input.boxId,
|
||||
gameId: input.gameId,
|
||||
linkedAccountId: input.linkedAccountId
|
||||
linkedAccountId: input.linkedAccountId,
|
||||
trial: input.trial ?? false
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => serialize(rows[0]!));
|
||||
@@ -119,7 +128,9 @@ export namespace Session {
|
||||
* answered is a timing detail.
|
||||
*/
|
||||
export const request = fn(
|
||||
Info.pick({ id: true, boxId: true, gameId: true, linkedAccountId: true }),
|
||||
Info.pick({ id: true, boxId: true, gameId: true, linkedAccountId: true }).extend({
|
||||
trial: z.boolean().optional()
|
||||
}),
|
||||
async (input) => {
|
||||
try {
|
||||
return await create(input);
|
||||
@@ -296,7 +307,7 @@ export namespace Session {
|
||||
* `kind` is on the wire while there is only one value, so that a second
|
||||
* kind is an addition rather than a redesign of the poll.
|
||||
*/
|
||||
export const Job = z
|
||||
export const StartJob = z
|
||||
.object({
|
||||
kind: z.literal('session.start').meta({
|
||||
description: 'What the agent is being asked to do',
|
||||
@@ -332,6 +343,38 @@ export namespace Session {
|
||||
description: 'One run waiting to be started, as handed to the agent that will start it'
|
||||
});
|
||||
|
||||
/**
|
||||
* A run the agent must end now, because the terms it started under have
|
||||
* run out. The agent ends it as it would end any run and reports `ended`;
|
||||
* the job repeats on every poll until it does.
|
||||
*/
|
||||
export const StopJob = z
|
||||
.object({
|
||||
kind: z.literal('session.stop').meta({
|
||||
description: 'What the agent is being asked to do',
|
||||
example: 'session.stop'
|
||||
}),
|
||||
sessionId: z.string().meta({
|
||||
description: 'The run to end',
|
||||
example: Examples.Session.id
|
||||
}),
|
||||
boxId: z.string().meta({
|
||||
description: 'The box it runs in',
|
||||
example: Examples.Session.boxId
|
||||
}),
|
||||
reason: z.enum(['window', 'hours']).meta({
|
||||
description:
|
||||
'`window`: the free weekend closed. `hours`: this weekend’s free hours are used',
|
||||
example: 'hours'
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
ref: 'SessionStopJob',
|
||||
description: 'One run to end now, as handed to the agent running it'
|
||||
});
|
||||
|
||||
export const Job = z.discriminatedUnion('kind', [StartJob, StopJob]);
|
||||
|
||||
export type Job = z.infer<typeof Job>;
|
||||
|
||||
/**
|
||||
@@ -342,8 +385,11 @@ export namespace Session {
|
||||
* and so what one set of long-lived credentials can see is decided by this
|
||||
* `where` clause rather than by whoever is holding them.
|
||||
*/
|
||||
export const listJobsForMachine = fn(z.string(), async (machineId) => {
|
||||
return Database.use(async (tx) => {
|
||||
export const listJobsForMachine = fn(z.string(), async (machineId): Promise<Job[]> => {
|
||||
const stops = (await Trial.overdue(machineId)).map(
|
||||
(run): Job => ({ kind: 'session.stop', ...run })
|
||||
);
|
||||
const starts = await Database.use(async (tx) => {
|
||||
return tx
|
||||
.select({ session: SessionTable, box: BoxTable, game: GameTable })
|
||||
.from(SessionTable)
|
||||
@@ -364,7 +410,7 @@ export namespace Session {
|
||||
kind: 'session.start',
|
||||
sessionId: row.session.id,
|
||||
boxId: row.box.id,
|
||||
boxTier: row.box.tier as Job['boxTier'],
|
||||
boxTier: row.box.tier as z.infer<typeof StartJob>['boxTier'],
|
||||
gameId: row.game.id,
|
||||
steamAppId: row.game.steamAppId,
|
||||
linkedAccountId: row.session.linkedAccountId
|
||||
@@ -372,6 +418,7 @@ export namespace Session {
|
||||
)
|
||||
);
|
||||
});
|
||||
return [...stops, ...starts];
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -664,7 +711,9 @@ export namespace Session {
|
||||
// Who pays is `Billing.teamForBox`'s answer and nobody else's: the
|
||||
// host's team on someone's own hardware, the box owner's personal
|
||||
// team on the fleet.
|
||||
const payer = await Billing.teamForBox(moved.boxId);
|
||||
// A trial run is counted by the trial, from the run's own times,
|
||||
// and not against the plan's allowance it is not drawing on.
|
||||
const payer = moved.trial ? null : await Billing.teamForBox(moved.boxId);
|
||||
if (payer) {
|
||||
if (current.state !== 'live' && moved.state === 'live') {
|
||||
// The rate is fixed here, from what this run actually is:
|
||||
@@ -789,6 +838,7 @@ export namespace Session {
|
||||
ticket: input.ticket,
|
||||
timeStarted: input.timeStarted?.toISOString() ?? null,
|
||||
timeStopped: input.timeStopped?.toISOString() ?? null,
|
||||
trial: input.trial,
|
||||
errorMessage: input.errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { index, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { boolean, index, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { BoxTable } from '../box/box.sql.js';
|
||||
import { id, timestamps, ulid, utc } from '../db/types.js';
|
||||
@@ -61,6 +61,11 @@ export const SessionTable = pgTable(
|
||||
/** Null until the box actually starts, which is not when the row appears. */
|
||||
timeStarted: utc('time_started'),
|
||||
timeStopped: utc('time_stopped'),
|
||||
// A run on rented GPUs under the free weekend rather than a plan. Its
|
||||
// time is counted against the trial and not the plan's allowance, and
|
||||
// it is the run that is stopped when the trial's hours or its window
|
||||
// run out. Fixed when the run is requested.
|
||||
trial: boolean('trial').notNull().default(false),
|
||||
/** Why it ended badly, when it did. */
|
||||
errorMessage: text('error_message'),
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { and, count, eq, isNull, sql } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { BoxTable } from '../box/box.sql.js';
|
||||
import { Database } from '../db/index.js';
|
||||
import { ErrorCodes, VisibleError } from '../error.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { MachineTable } from '../machine/machine.sql.js';
|
||||
import { SessionTable } from '../session/session.sql.js';
|
||||
import { TeamTable } from '../team/team.sql.js';
|
||||
import { LinkedAccountTable } from '../user/linked-account.sql.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
import { TrialClaimTable } from './trial.sql.js';
|
||||
|
||||
/**
|
||||
* The free weekend: play on rented GPUs without a plan, a little, at weekends.
|
||||
*
|
||||
* Three rules and nothing else:
|
||||
*
|
||||
* - **When.** Friday 00:00 to Monday 00:00, on the clock of the place the
|
||||
* hardware is run from. Outside that, a trial run is refused, and one still
|
||||
* going when it closes is stopped.
|
||||
* - **How much.** {@link HOURS} of live time per window, across every run.
|
||||
* Unlike a plan's allowance, this one stops a run that reaches it: the
|
||||
* trial is the hardware lent, not a budget being drawn down.
|
||||
* - **For how long.** Until {@link PAID_SEATS} teams pay for a plan. Then the
|
||||
* hardware is theirs and the trial closes for everyone.
|
||||
*
|
||||
* Downloading a game is not playing it and is never refused here; only a run
|
||||
* is.
|
||||
*/
|
||||
export namespace Trial {
|
||||
export const HOURS = 2;
|
||||
export const PAID_SEATS = 5;
|
||||
export const ZONE = 'Europe/Helsinki';
|
||||
|
||||
const SECONDS = HOURS * 3600;
|
||||
|
||||
/**
|
||||
* What time it is, for every rule here. Replaceable so a test can stand on
|
||||
* a Saturday; nothing else has a reason to touch it.
|
||||
*/
|
||||
export let now = () => new Date();
|
||||
export function useClock(clock: () => Date) {
|
||||
now = clock;
|
||||
}
|
||||
|
||||
/** A zone's UTC offset at one instant, in milliseconds. */
|
||||
function offsetMs(at: Date): number {
|
||||
const name = new Intl.DateTimeFormat('en-US', { timeZone: ZONE, timeZoneName: 'longOffset' })
|
||||
.formatToParts(at)
|
||||
.find((p) => p.type === 'timeZoneName')!.value;
|
||||
const m = /GMT([+-])(\d{2}):?(\d{2})?/.exec(name);
|
||||
if (!m) return 0;
|
||||
const sign = m[1] === '-' ? -1 : 1;
|
||||
return sign * (Number(m[2]) * 60 + Number(m[3] ?? 0)) * 60_000;
|
||||
}
|
||||
|
||||
/** Midnight at the start of a calendar day in {@link ZONE}, as an instant. */
|
||||
function midnight(year: number, month: number, day: number): Date {
|
||||
const guess = Date.UTC(year, month - 1, day);
|
||||
// Twice, because the offset at the guess and at the answer can differ by
|
||||
// the hour a clock change moved.
|
||||
const once = guess - offsetMs(new Date(guess));
|
||||
return new Date(guess - offsetMs(new Date(once)));
|
||||
}
|
||||
|
||||
/** The calendar day an instant falls on in {@link ZONE}, and its weekday (Mon 1 … Sun 7). */
|
||||
function localDay(at: Date) {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: ZONE,
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
weekday: 'short'
|
||||
})
|
||||
.formatToParts(at)
|
||||
.map((p) => [p.type, p.value])
|
||||
);
|
||||
const weekday = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].indexOf(parts.weekday!) + 1;
|
||||
return {
|
||||
year: Number(parts.year),
|
||||
month: Number(parts.month),
|
||||
day: Number(parts.day),
|
||||
weekday
|
||||
};
|
||||
}
|
||||
|
||||
function addDays(d: { year: number; month: number; day: number }, n: number) {
|
||||
const t = new Date(Date.UTC(d.year, d.month - 1, d.day + n));
|
||||
return { year: t.getUTCFullYear(), month: t.getUTCMonth() + 1, day: t.getUTCDate() };
|
||||
}
|
||||
|
||||
export interface Window {
|
||||
/** Whether `at` is inside it. */
|
||||
open: boolean;
|
||||
/** The window `at` is in, or the next one. */
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
/** The weekend window an instant is in, or the next one if it is in none. */
|
||||
export function window(at: Date = now()): Window {
|
||||
const today = localDay(at);
|
||||
const sinceFriday = (today.weekday - 5 + 7) % 7; // Fri 0, Sat 1, Sun 2, Mon 3 …
|
||||
const open = sinceFriday <= 2;
|
||||
const friday = addDays(today, open ? -sinceFriday : 7 - sinceFriday);
|
||||
const monday = addDays(friday, 3);
|
||||
return {
|
||||
open,
|
||||
start: midnight(friday.year, friday.month, friday.day),
|
||||
end: midnight(monday.year, monday.month, monday.day)
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the trial is still offered: fewer than {@link PAID_SEATS} teams pay. */
|
||||
export async function offered(): Promise<boolean> {
|
||||
const [row] = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ paid: count() })
|
||||
.from(TeamTable)
|
||||
.where(and(eq(TeamTable.plan, 'paid'), isNull(TeamTable.timeDeleted)))
|
||||
);
|
||||
return (row?.paid ?? 0) < PAID_SEATS;
|
||||
}
|
||||
|
||||
/** Live seconds of a person's trial runs inside `[start, end)`, up to `now`. */
|
||||
export async function usedSeconds(userId: string, w: Window, at: Date = now()) {
|
||||
const [row] = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
seconds: sql<string>`coalesce(sum(greatest(0, extract(epoch from
|
||||
least(coalesce(${SessionTable.timeStopped}, ${at.toISOString()}::timestamptz), ${w.end.toISOString()}::timestamptz)
|
||||
- greatest(${SessionTable.timeStarted}, ${w.start.toISOString()}::timestamptz)))), 0)`
|
||||
})
|
||||
.from(SessionTable)
|
||||
.innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(BoxTable.userId, userId),
|
||||
eq(SessionTable.trial, true),
|
||||
sql`${SessionTable.timeStarted} is not null`,
|
||||
isNull(SessionTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
);
|
||||
return Math.floor(Number(row?.seconds ?? 0));
|
||||
}
|
||||
|
||||
function when(at: Date): string {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: ZONE,
|
||||
weekday: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
}).format(at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a trial run that may not start, or record the claim and let it.
|
||||
*
|
||||
* The order is the order a person can act on: closed for everyone first,
|
||||
* then "not you" (somebody already used this address or account), then
|
||||
* "not now" (outside the window, or this window's hours are spent).
|
||||
*/
|
||||
export const assertMayStart = fn(
|
||||
z.object({
|
||||
teamId: z.string(),
|
||||
userId: z.string(),
|
||||
linkedAccountId: z.string(),
|
||||
now: z.date().optional()
|
||||
}),
|
||||
async (input) => {
|
||||
const at = input.now ?? now();
|
||||
if (!(await offered())) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.ACCOUNT_RESTRICTED,
|
||||
'The free weekend has closed: Nestri GPUs are now taken by paying teams. A plan gets you on.'
|
||||
);
|
||||
}
|
||||
|
||||
await claim(input);
|
||||
|
||||
const w = window(at);
|
||||
if (!w.open) {
|
||||
throw new VisibleError(
|
||||
'rate_limit',
|
||||
ErrorCodes.RateLimit.QUOTA_EXCEEDED,
|
||||
`Free play on Nestri GPUs is Friday to Sunday. The next window opens ${when(w.start)}.`
|
||||
);
|
||||
}
|
||||
if ((await usedSeconds(input.userId, w, at)) >= SECONDS) {
|
||||
const next = window(w.end);
|
||||
throw new VisibleError(
|
||||
'rate_limit',
|
||||
ErrorCodes.RateLimit.QUOTA_EXCEEDED,
|
||||
`Your ${HOURS} free hours this weekend are used. The next window opens ${when(next.start)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* The team's claim, made on its first trial run.
|
||||
*
|
||||
* Checked by reading, then held by the unique indexes: two first runs in
|
||||
* the same instant both read "unclaimed", and the second insert is refused
|
||||
* with the same answer the read would have given.
|
||||
*/
|
||||
async function claim(input: { teamId: string; userId: string; linkedAccountId: string }) {
|
||||
const existing = await Database.use((tx) =>
|
||||
tx.select().from(TrialClaimTable).where(eq(TrialClaimTable.teamId, input.teamId))
|
||||
);
|
||||
if (existing.length > 0) return;
|
||||
|
||||
const [person] = await Database.use((tx) =>
|
||||
tx.select({ email: UserTable.email }).from(UserTable).where(eq(UserTable.id, input.userId))
|
||||
);
|
||||
const [linked] = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ steamId: LinkedAccountTable.providerAccountId })
|
||||
.from(LinkedAccountTable)
|
||||
.where(eq(LinkedAccountTable.id, input.linkedAccountId))
|
||||
);
|
||||
const email = person?.email?.trim().toLowerCase();
|
||||
if (!email || !linked) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.ACCOUNT_RESTRICTED,
|
||||
'The free weekend needs a verified email address and a linked Steam account.'
|
||||
);
|
||||
}
|
||||
|
||||
const used = () =>
|
||||
new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.ACCOUNT_RESTRICTED,
|
||||
'This email address or Steam account has already had the free weekend. A plan gets you on.'
|
||||
);
|
||||
const taken = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ id: TrialClaimTable.id })
|
||||
.from(TrialClaimTable)
|
||||
.where(
|
||||
sql`lower(${TrialClaimTable.email}) = ${email} or ${TrialClaimTable.steamId} = ${linked.steamId}`
|
||||
)
|
||||
.limit(1)
|
||||
);
|
||||
if (taken.length > 0) throw used();
|
||||
|
||||
try {
|
||||
await Database.use((tx) =>
|
||||
tx.insert(TrialClaimTable).values({
|
||||
id: Identifier.ascending('trialClaim'),
|
||||
teamId: input.teamId,
|
||||
userId: input.userId,
|
||||
email,
|
||||
steamId: linked.steamId
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; cause?: { code?: string } };
|
||||
if (e?.code === '23505' || e?.cause?.code === '23505') throw used();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The trial runs on one machine that must be stopped now: live, and either
|
||||
* outside the window or past the hours. Asked when the host polls for
|
||||
* work, so a run is stopped within one poll of crossing the line.
|
||||
*/
|
||||
export async function overdue(machineId: string, at: Date = now()) {
|
||||
const live = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ sessionId: SessionTable.id, boxId: BoxTable.id, userId: BoxTable.userId })
|
||||
.from(SessionTable)
|
||||
.innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id))
|
||||
.innerJoin(MachineTable, eq(BoxTable.machineId, MachineTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(BoxTable.machineId, machineId),
|
||||
eq(SessionTable.trial, true),
|
||||
eq(SessionTable.state, 'live'),
|
||||
isNull(SessionTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
);
|
||||
const w = window(at);
|
||||
const out: { sessionId: string; boxId: string; reason: 'window' | 'hours' }[] = [];
|
||||
for (const run of live) {
|
||||
if (!w.open) out.push({ sessionId: run.sessionId, boxId: run.boxId, reason: 'window' });
|
||||
else if ((await usedSeconds(run.userId, w, at)) >= SECONDS) {
|
||||
out.push({ sessionId: run.sessionId, boxId: run.boxId, reason: 'hours' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, ulid } from '../db/types.js';
|
||||
import { TeamTable } from '../team/team.sql.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
|
||||
/**
|
||||
* Who has had the free weekend on rented GPUs.
|
||||
*
|
||||
* One per team, one per email address and one per Steam account: an address
|
||||
* costs nothing to make, an account that owns games costs a good deal more, so
|
||||
* a second trial needs both a new address and a new Steam account.
|
||||
*
|
||||
* The row outlives the account that made it. Deleting the team or the user
|
||||
* clears those links and keeps the address and the Steam id, so closing an
|
||||
* account and signing up again is not a second trial.
|
||||
*/
|
||||
export const TrialClaimTable = pgTable(
|
||||
'trial_claim',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
teamId: ulid('team_id').references(() => TeamTable.id, { onDelete: 'set null' }),
|
||||
userId: ulid('user_id').references(() => UserTable.id, { onDelete: 'set null' }),
|
||||
/** Lowercased. */
|
||||
email: text('email').notNull(),
|
||||
/** The Steam account's 64-bit id, as text. */
|
||||
steamId: text('steam_id').notNull()
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('trial_claim_email_unique').on(t.email),
|
||||
uniqueIndex('trial_claim_steam_unique').on(t.steamId),
|
||||
uniqueIndex('trial_claim_team_unique').on(t.teamId),
|
||||
index('trial_claim_user_idx').on(t.userId)
|
||||
]
|
||||
);
|
||||
Reference in New Issue
Block a user