feat(session): a finished session says why it ended (#412)

Until now every finished run said only `ended` or `failed`. A user stop,
a trial window running out, an idle timeout, a host restart and a game
quitting were indistinguishable afterwards.

**Schema.** Migration `0026_session_end_reason` adds two nullable
columns to `session`:
- `end_reason`: one of `user`, `window`, `hours`, `idle`, `left_queue`,
`game_exited`, `launch_failed`, `host_stopped`, `host_restart`.
- `time_claimed`: when a host took the run, set on `requested →
starting`.

**API.**
- `POST /session/:id/state` takes an optional `endReason`. With `ended`
or `failed` it is stored. With `starting` or `live` it is a 400 and the
run does not move.
- Leaving it out of a terminal report stores null, so host agents that
predate this keep working.
- `endReason` and `timeClaimed` are returned on every session response.
- The API writes the reasons it decides itself: `user` when a run nobody
has claimed is stopped, and `left_queue` when a waiting run is abandoned
(the `Left the queue` message is kept).

**New transition, `starting → ended`.** A run stopped while its game is
still downloading has failed at nothing, and never reached `live`, which
is where billing starts. Until now the only way out of `starting` was
`failed`.

**Release order: this goes first.** The host agent that sends
`endReason` is a separate change. This route's body schema is strict, so
without this deployed the current API answers that agent with a 400.

**Tests.**
- API: 185 pass, 5 of them new.
- Core: 41 session tests pass, 3 of them new.
- The new tests fail when the implementation is broken on purpose.
- No new type errors.
This commit is contained in:
Wanjohi
2026-10-16 00:20:49 +03:00
committed by GitHub
parent cf0606a81a
commit b50dce2cef
10 changed files with 4176 additions and 17 deletions
+22 -2
View File
@@ -88,10 +88,18 @@ export namespace SessionApi {
errorMessage: z.string().max(1024).nullable().optional().meta({ errorMessage: z.string().max(1024).nullable().optional().meta({
description: 'Why it failed. Kept only for a run that did', description: 'Why it failed. Kept only for a run that did',
example: Examples.Session.errorMessage example: Examples.Session.errorMessage
}),
endReason: Session.EndReason.nullable().optional().meta({
description:
'Why the run is over. Only with `ended` or `failed`; leave it out and the run records no reason',
example: 'game_exited'
}) })
}) })
.strict(); .strict();
/** The states a run is over in, and so the only ones a reason goes with. */
const TERMINAL: readonly Session.ReportableState[] = ['ended', 'failed'];
export const route = new Hono() export const route = new Hono()
.get( .get(
'/', '/',
@@ -385,7 +393,7 @@ export namespace SessionApi {
tags: ['Session'], tags: ['Session'],
summary: 'Report where a run has got to', summary: 'Report where a run has got to',
description: description:
'For the host the run’s box is placed on, and no other. Moving a run out of `requested` is the claim, and it is a compare-and-set: exactly one caller can take a given run, and one that loses gets 409. Re-reporting a state already reported is fine and changes nothing, including the timestamps a run is billed on. A transition that does not exist is 409 and the run does not move.', 'For the host the run’s box is placed on, and no other. Moving a run out of `requested` is the claim, and it is a compare-and-set: exactly one caller can take a given run, and one that loses gets 409. Re-reporting a state already reported is fine and changes nothing, including the timestamps a run is billed on. A transition that does not exist is 409 and the run does not move. Say why a run ended with `endReason` on the report that ends it; sending one with any other state is 400.',
responses: { responses: {
200: { 200: {
content: { 'application/json': { schema: Result(Session.Info) } }, content: { 'application/json': { schema: Result(Session.Info) } },
@@ -400,12 +408,24 @@ export namespace SessionApi {
validator('json', StateReport), validator('json', StateReport),
async (c) => { async (c) => {
const body = c.req.valid('json'); const body = c.req.valid('json');
// Refused rather than dropped. A reason on a run that is still
// going is a host agent that has its states and its reasons out
// of step, and storing nothing would hide that from it.
if (body.endReason && !TERMINAL.includes(body.state)) {
throw new VisibleError(
'validation',
ErrorCodes.Validation.INVALID_PARAMETER,
`A run that is ${body.state} is not over, so it has no reason to have ended`,
'endReason'
);
}
const result = await Session.transition({ const result = await Session.transition({
id: c.req.valid('param').id, id: c.req.valid('param').id,
machineId: Actor.machineID, machineId: Actor.machineID,
state: body.state, state: body.state,
claimToken: body.claimToken, claimToken: body.claimToken,
errorMessage: body.errorMessage ?? null errorMessage: body.errorMessage ?? null,
endReason: body.endReason ?? null
}); });
switch (result.outcome) { switch (result.outcome) {
+5 -1
View File
@@ -146,7 +146,11 @@ describe('the line for a full host', () => {
await Session.setState({ id: playing.run.id, state: 'ended' }); await Session.setState({ id: playing.run.id, state: 'ended' });
expect((await h.jobs()).map((j) => j.sessionId)).toEqual([here.run.id]); expect((await h.jobs()).map((j) => j.sessionId)).toEqual([here.run.id]);
const left = await Session.fromID(gone.run.id); const left = await Session.fromID(gone.run.id);
expect(left).toMatchObject({ state: 'ended', errorMessage: 'Left the queue' }); expect(left).toMatchObject({
state: 'ended',
errorMessage: 'Left the queue',
endReason: 'left_queue'
});
}); });
test('a run with a slot is not in line', async () => { test('a run with a slot is not in line', async () => {
+113 -2
View File
@@ -160,11 +160,13 @@ describe('POST /session', () => {
state: 'requested', state: 'requested',
tier: null, tier: null,
ticket: null, ticket: null,
timeClaimed: null,
timeStarted: null, timeStarted: null,
timeStopped: null, timeStopped: null,
timeStopRequested: null, timeStopRequested: null,
trial: false, trial: false,
errorMessage: null errorMessage: null,
endReason: null
}); });
expect(body.data.id.startsWith('ses_')).toBe(true); expect(body.data.id.startsWith('ses_')).toBe(true);
}); });
@@ -478,7 +480,11 @@ describe('POST /session/:id/state', () => {
body: JSON.stringify({ state: 'starting', claimToken: HOLDER }) body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(((await res.json()) as any).data.state).toBe('starting'); const claimed = ((await res.json()) as any).data;
expect(claimed.state).toBe('starting');
// The time it was taken is what bring-up is measured from.
expect(body.data.timeClaimed).toBeNull();
expect(claimed.timeClaimed).not.toBeNull();
const jobs = await app.request('/machine/jobs', { headers: s.host }); const jobs = await app.request('/machine/jobs', { headers: s.host });
expect(((await jobs.json()) as any).data).toEqual([]); expect(((await jobs.json()) as any).data).toEqual([]);
@@ -638,6 +644,106 @@ describe('POST /session/:id/state', () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
test('the report that ends a run says why, and the owner reads it', async () => {
const s = await scene('route-reason-ended', 5570);
const { body } = await requestSession(s);
const report = (state: string, endReason?: string) =>
app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state, claimToken: HOLDER, endReason })
});
await report('starting');
await report('live');
const ended = await report('ended', 'game_exited');
expect(ended.status).toBe(200);
expect(((await ended.json()) as any).data.endReason).toBe('game_exited');
const read = await app.request(`/session/${body.data.id}`, { headers: s.user });
expect(((await read.json()) as any).data.endReason).toBe('game_exited');
});
test('a run that failed says why it is over, beside what went wrong', async () => {
const s = await scene('route-reason-failed', 5571);
const { body } = await requestSession(s);
const report = (payload: Record<string, unknown>) =>
app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ claimToken: HOLDER, ...payload })
});
await report({ state: 'starting' });
const failed = await report({
state: 'failed',
errorMessage: 'the download could not finish',
endReason: 'launch_failed'
});
expect(failed.status).toBe(200);
expect(((await failed.json()) as any).data).toMatchObject({
state: 'failed',
errorMessage: 'the download could not finish',
endReason: 'launch_failed'
});
});
test('a reason with a run that is not over is refused, and the run stays put', async () => {
const s = await scene('route-reason-early', 5572);
const { body } = await requestSession(s);
await app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
});
const res = await app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state: 'live', claimToken: HOLDER, endReason: 'idle' })
});
expect(res.status).toBe(400);
expect(((await res.json()) as any).param).toBe('endReason');
const run = await Session.fromID(body.data.id);
expect(run?.state).toBe('starting');
expect(run?.endReason).toBeNull();
});
test('a reason nobody defined is a validation error', async () => {
const s = await scene('route-reason-bogus', 5573);
const { body } = await requestSession(s);
await app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
});
const res = await app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state: 'failed', claimToken: HOLDER, endReason: 'bored' })
});
expect(res.status).toBe(400);
expect((await Session.fromID(body.data.id))?.state).toBe('starting');
});
test('a host agent that sends no reason still ends the run, with none recorded', async () => {
const s = await scene('route-reason-absent', 5574);
const { body } = await requestSession(s);
const report = (state: string) =>
app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state, claimToken: HOLDER })
});
await report('starting');
await report('live');
// The report every host agent already in the field sends.
const ended = await report('ended');
expect(ended.status).toBe(200);
expect(((await ended.json()) as any).data).toMatchObject({ state: 'ended', endReason: null });
});
test('a person cannot report a state on their own session', async () => { test('a person cannot report a state on their own session', async () => {
const s = await scene('route-claim-person', 5538); const s = await scene('route-claim-person', 5538);
const { body } = await requestSession(s); const { body } = await requestSession(s);
@@ -721,6 +827,7 @@ describe('POST /session/:id/ticket', () => {
const data = ((await read.json()) as any).data; const data = ((await read.json()) as any).data;
expect(Object.keys(data).sort()).toEqual([ expect(Object.keys(data).sort()).toEqual([
'boxId', 'boxId',
'endReason',
'errorMessage', 'errorMessage',
'gameId', 'gameId',
'id', 'id',
@@ -729,6 +836,7 @@ describe('POST /session/:id/ticket', () => {
'state', 'state',
'ticket', 'ticket',
'tier', 'tier',
'timeClaimed',
'timeStarted', 'timeStarted',
'timeStopRequested', 'timeStopRequested',
'timeStopped', 'timeStopped',
@@ -901,6 +1009,9 @@ describe('POST /session/:id/stop', () => {
const out = (await res.json()) as any; const out = (await res.json()) as any;
expect(out.data.state).toBe('ended'); expect(out.data.state).toBe('ended');
expect(out.data.timeStopped).not.toBeNull(); expect(out.data.timeStopped).not.toBeNull();
// Ended here rather than by a host, so the reason is written here too.
expect(out.data.endReason).toBe('user');
expect(out.data.timeClaimed).toBeNull();
const res2 = await app.request('/machine/jobs', { headers: s.host }); const res2 = await app.request('/machine/jobs', { headers: s.host });
expect(((await res2.json()) as any).data).toEqual([]); expect(((await res2.json()) as any).data).toEqual([]);
}); });
@@ -0,0 +1,2 @@
ALTER TABLE "session" ADD COLUMN "end_reason" text;--> statement-breakpoint
ALTER TABLE "session" ADD COLUMN "time_claimed" timestamp with time zone;
File diff suppressed because it is too large Load Diff
@@ -183,6 +183,13 @@
"when": 1791715304549, "when": 1791715304549,
"tag": "0025_run_queue", "tag": "0025_run_queue",
"breakpoints": true "breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1792095717042,
"tag": "0026_session_end_reason",
"breakpoints": true
} }
] ]
} }
+3 -1
View File
@@ -177,9 +177,11 @@ export namespace Examples {
// Shaped like the real thing so the docs do not teach a shorter one, // Shaped like the real thing so the docs do not teach a shorter one,
// and obviously not one. // and obviously not one.
claimToken: '00000000000000000000000000000000', claimToken: '00000000000000000000000000000000',
timeClaimed: '2026-07-28T11:59:30.000Z',
timeStarted: '2026-07-28T12:00:00.000Z', timeStarted: '2026-07-28T12:00:00.000Z',
timeStopped: null, timeStopped: null,
errorMessage: null errorMessage: null,
endReason: null
}; };
export const GameDownload = { export const GameDownload = {
+78 -11
View File
@@ -56,6 +56,36 @@ export namespace Session {
*/ */
export const ABANDONED_AFTER_SECONDS = 60; export const ABANDONED_AFTER_SECONDS = 60;
/**
* Why a run is over.
*
* `ended` and `failed` say how a run finished, not why, and a stop its
* owner asked for, a free weekend running out and a game quitting on its
* own all read `ended` without this. Whoever ends the run writes it: the
* control plane for the two endings it decides itself, `user` on a run
* nobody had claimed and `left_queue`, and the host agent for everything
* else, in its terminal report. ref(d-0083)
*/
export const EndReason = z
.enum([
'user',
'window',
'hours',
'idle',
'left_queue',
'game_exited',
'launch_failed',
'host_stopped',
'host_restart'
])
.meta({
description:
'Why a run is over. `user`: its owner asked. `window`: the free weekend closed. `hours`: this weekend’s free hours are used. `idle`: nobody was connected to it. `left_queue`: it waited for a slot and nobody asked after it. `game_exited`: the game quit on its own. `launch_failed`: it never got as far as playing. `host_stopped`: its host ended it for a reason of its own. `host_restart`: its host restarted and the run did not survive it',
example: 'user'
});
export type EndReason = z.infer<typeof EndReason>;
export const Info = z export const Info = z
.object({ .object({
id: z.string().meta({ id: z.string().meta({
@@ -87,6 +117,11 @@ export namespace Session {
description: 'The size this run was asked for. Null runs at the size of its box', description: 'The size this run was asked for. Null runs at the size of its box',
example: 'md' example: 'md'
}), }),
timeClaimed: z.string().nullable().optional().meta({
description:
'When its host took the run on. Null while it waits for one, and for a run that never had one',
example: Examples.Session.timeClaimed
}),
timeStarted: z.string().nullable().optional().meta({ timeStarted: z.string().nullable().optional().meta({
description: 'When the box actually started, not when the row appeared', description: 'When the box actually started, not when the row appeared',
example: Examples.Session.timeStarted example: Examples.Session.timeStarted
@@ -109,6 +144,11 @@ export namespace Session {
description: 'Why it failed, when it did', description: 'Why it failed, when it did',
example: Examples.Session.errorMessage example: Examples.Session.errorMessage
}), }),
endReason: EndReason.nullable().optional().meta({
description:
'Why this run is over, once it is. Null while it runs, and for a run whose host did not say',
example: Examples.Session.endReason
}),
queue: Queue.nullable().optional().meta({ queue: Queue.nullable().optional().meta({
description: description:
'Where this run is in line for its host, while every slot there is taken. Null once it is not waiting' 'Where this run is in line for its host, while every slot there is taken. Null once it is not waiting'
@@ -261,10 +301,11 @@ export namespace Session {
* *
* `live` stamps `timeStarted` and `ended`/`failed` stamp `timeStopped`, both * `live` stamps `timeStarted` and `ended`/`failed` stamp `timeStopped`, both
* only if unset — so a duplicate report does not extend a session someone is * only if unset — so a duplicate report does not extend a session someone is
* billed for, and metering can trust the pair. * billed for, and metering can trust the pair. A reason is kept only for a
* run that is over, as an error message is kept only for one that failed.
*/ */
export const setState = fn( export const setState = fn(
Info.pick({ id: true, state: true, errorMessage: true }), Info.pick({ id: true, state: true, errorMessage: true, endReason: true }),
async (input) => { async (input) => {
const now = sql`now()`; const now = sql`now()`;
return Database.use(async (tx) => { return Database.use(async (tx) => {
@@ -273,6 +314,10 @@ export namespace Session {
.set({ .set({
state: input.state, state: input.state,
errorMessage: input.state === 'failed' ? (input.errorMessage ?? null) : null, errorMessage: input.state === 'failed' ? (input.errorMessage ?? null) : null,
endReason:
input.state === 'ended' || input.state === 'failed'
? (input.endReason ?? null)
: null,
...(input.state === 'live' ...(input.state === 'live'
? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` } ? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` }
: {}), : {}),
@@ -325,10 +370,14 @@ export namespace Session {
* the only mutual exclusion in this design. `ended` and `failed` are * the only mutual exclusion in this design. `ended` and `failed` are
* terminal, so their entries are empty rather than absent — a state with no * terminal, so their entries are empty rather than absent — a state with no
* exits is a fact worth writing down. * exits is a fact worth writing down.
*
* `starting → ended` is a run stopped before it went live — the person
* stopping it while its game downloads, say. Nothing failed, so `failed`
* would be a lie, and it never reached `live`, so it was never billed.
*/ */
export const NEXT_STATES: Record<Info['state'], readonly Info['state'][]> = { export const NEXT_STATES: Record<Info['state'], readonly Info['state'][]> = {
requested: ['starting'], requested: ['starting'],
starting: ['live', 'failed'], starting: ['live', 'ended', 'failed'],
live: ['ended', 'failed'], live: ['ended', 'failed'],
ended: [], ended: [],
failed: [] failed: []
@@ -541,7 +590,8 @@ export namespace Session {
.set({ .set({
state: 'ended', state: 'ended',
timeStopped: sql`now()`, timeStopped: sql`now()`,
errorMessage: 'Left the queue' errorMessage: 'Left the queue',
endReason: 'left_queue' satisfies EndReason
}) })
.where( .where(
and( and(
@@ -712,7 +762,12 @@ export namespace Session {
// the same moment wins or loses cleanly, never both. // the same moment wins or loses cleanly, never both.
const [cancelled] = await tx const [cancelled] = await tx
.update(SessionTable) .update(SessionTable)
.set({ state: 'ended', timeStopRequested: sql`now()`, timeStopped: sql`now()` }) .set({
state: 'ended',
timeStopRequested: sql`now()`,
timeStopped: sql`now()`,
endReason: 'user' satisfies EndReason
})
.where(and(scope, eq(SessionTable.state, 'requested'))) .where(and(scope, eq(SessionTable.state, 'requested')))
.returning(); .returning();
if (cancelled) return serialize(cancelled); if (cancelled) return serialize(cancelled);
@@ -756,7 +811,8 @@ export namespace Session {
from: z.enum(SessionState.enumValues), from: z.enum(SessionState.enumValues),
to: z.enum(SessionState.enumValues), to: z.enum(SessionState.enumValues),
claimToken: ClaimToken, claimToken: ClaimToken,
errorMessage: Info.shape.errorMessage errorMessage: Info.shape.errorMessage,
endReason: Info.shape.endReason
}), }),
async (input) => { async (input) => {
const now = sql`now()`; const now = sql`now()`;
@@ -772,9 +828,16 @@ export namespace Session {
state: input.to, state: input.to,
// Taken on the claim and never written again: a terminal row // Taken on the claim and never written again: a terminal row
// still records which attempt ran it, and a holder that goes // still records which attempt ran it, and a holder that goes
// back to null lets a settled claim be replayed. // back to null lets a settled claim be replayed. The time is
...(isClaim ? { claimToken: input.claimToken } : {}), // taken with it, so a run's bring-up can be measured from
// the moment a host had it rather than from when it asked.
...(isClaim ? { claimToken: input.claimToken, timeClaimed: now } : {}),
errorMessage: input.to === 'failed' ? (input.errorMessage ?? null) : null, errorMessage: input.to === 'failed' ? (input.errorMessage ?? null) : null,
// Only a run that is over has a reason to be over. Null when
// the report carries none, which is every report from a
// host agent that predates the field.
endReason:
input.to === 'ended' || input.to === 'failed' ? (input.endReason ?? null) : null,
...(input.to === 'live' ...(input.to === 'live'
? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` } ? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` }
: {}), : {}),
@@ -889,7 +952,8 @@ export namespace Session {
machineId: z.string(), machineId: z.string(),
state: z.enum(SessionState.enumValues), state: z.enum(SessionState.enumValues),
claimToken: ClaimToken, claimToken: ClaimToken,
errorMessage: Info.shape.errorMessage errorMessage: Info.shape.errorMessage,
endReason: Info.shape.endReason
}), }),
async (input): Promise<TransitionResult> => { async (input): Promise<TransitionResult> => {
// One transaction, because "this run is live" and "the box under it // One transaction, because "this run is live" and "the box under it
@@ -933,7 +997,8 @@ export namespace Session {
from: current.state, from: current.state,
to: input.state, to: input.state,
claimToken: input.claimToken, claimToken: input.claimToken,
errorMessage: input.errorMessage errorMessage: input.errorMessage,
endReason: input.endReason
}); });
// The state read above is not the state written below, and the // The state read above is not the state written below, and the
// gap is where two agents race. Nothing moved means somebody // gap is where two agents race. Nothing moved means somebody
@@ -1084,11 +1149,13 @@ export namespace Session {
state: input.state as Info['state'], state: input.state as Info['state'],
tier: input.tier as Info['tier'], tier: input.tier as Info['tier'],
ticket: input.ticket, ticket: input.ticket,
timeClaimed: input.timeClaimed?.toISOString() ?? null,
timeStarted: input.timeStarted?.toISOString() ?? null, timeStarted: input.timeStarted?.toISOString() ?? null,
timeStopped: input.timeStopped?.toISOString() ?? null, timeStopped: input.timeStopped?.toISOString() ?? null,
timeStopRequested: input.timeStopRequested?.toISOString() ?? null, timeStopRequested: input.timeStopRequested?.toISOString() ?? null,
trial: input.trial, trial: input.trial,
errorMessage: input.errorMessage errorMessage: input.errorMessage,
endReason: input.endReason as Info['endReason']
}; };
} }
} }
+12
View File
@@ -79,6 +79,18 @@ export const SessionTable = pgTable(
trial: boolean('trial').notNull().default(false), trial: boolean('trial').notNull().default(false),
/** Why it ended badly, when it did. */ /** Why it ended badly, when it did. */
errorMessage: text('error_message'), errorMessage: text('error_message'),
/**
* Why the run is over, in one word from `Session.EndReason`, written by
* whoever ended it. Null while it runs, and null for a run ended by a
* host agent too old to say. Text rather than a database enum, so a
* reason added later is a code change and not a migration.
*/
endReason: text('end_reason'),
/**
* When a host agent claimed this run, or null while it waits. The gap
* between this and `timeStarted` is the time spent bringing it up.
*/
timeClaimed: utc('time_claimed'),
/** /**
* Which attempt holds this run. Null until an agent claims it, and never * Which attempt holds this run. Null until an agent claims it, and never
* cleared afterwards — a terminal row still records who ran it, and a * cleared afterwards — a terminal row still records who ran it, and a
+57
View File
@@ -1157,3 +1157,60 @@ describe('Session tickets need a claim first', () => {
expect(closed.outcome).toBe('closed'); expect(closed.outcome).toBe('closed');
}); });
}); });
describe('Session end reasons', () => {
const report = (
run: { machineId: string; session: { id: string } },
state: Session.ReportableState,
endReason: Session.EndReason | null = null
) =>
Session.transition({
claimToken: HOLDER,
id: run.session.id,
machineId: run.machineId,
state,
errorMessage: null,
endReason
});
test('the claim stamps when a host took the run, and nothing before it does', async () => {
const run = await requestedRun('ses-claimed-at', 5470);
expect(run.session.timeClaimed).toBeNull();
const claimed = await report(run, 'starting');
expect(claimed.session?.timeClaimed).not.toBeNull();
// Going live is not a second claim, so the time a host took the run
// stays the time it took it.
const live = await report(run, 'live');
expect(live.session?.timeClaimed).toBe(claimed.session!.timeClaimed!);
});
test('a reason is kept only for a run that is over', async () => {
const run = await requestedRun('ses-reason-terminal', 5471);
await report(run, 'starting');
// The write itself refuses to give a running run a reason, whoever
// calls it, so no row can say it ended while it is still going.
const live = await report(run, 'live', 'idle');
expect(live.outcome).toBe('moved');
expect(live.session?.endReason).toBeNull();
const ended = await report(run, 'ended', 'idle');
expect(ended.session?.endReason).toBe('idle');
expect((await Session.fromID(run.session.id))?.endReason).toBe('idle');
});
test('a run stopped before it went live ends, with its reason, and was never billed', async () => {
const run = await requestedRun('ses-stopped-starting', 5472);
await report(run, 'starting');
// Stopped while its game downloaded: nothing failed, and it never
// reached `live`, which is where billing starts.
const ended = await report(run, 'ended', 'user');
expect(ended.outcome).toBe('moved');
expect(ended.session?.state).toBe('ended');
expect(ended.session?.endReason).toBe('user');
expect(ended.session?.timeStarted).toBeNull();
});
});