A finished session says why it ended; bash <(curl …) -u updates a host (#413)

- **Sessions (#412):** `end_reason` and `time_claimed`, with migration
`0026_session_end_reason` (two nullable columns, readable by the release
it replaces). `POST /session/:id/state` takes an optional `endReason`,
and a run stopped before it went live can now go from `starting` to
`ended`.
- **Installer (#411):**
- `bash <(curl -fsSL https://api.nestri.io/install.sh) -u` updates a
host; `-h` prints help; a plain token installs.
  - When the script is not root, it asks sudo itself.
  - The installed host agent version is unchanged (0.2.18).

**Ships before host agent 0.2.19**, which sends `endReason` and the new
transition.

Cut from prod with dev's files, so squash histories cannot conflict; the
tree is identical to dev.
This commit is contained in:
Wanjohi
2026-10-15 21:35:26 +00:00
committed by GitHub
parent 21c80ecab5
commit ed465dae51
11 changed files with 4278 additions and 32 deletions
+22 -2
View File
@@ -88,10 +88,18 @@ export namespace SessionApi {
errorMessage: z.string().max(1024).nullable().optional().meta({
description: 'Why it failed. Kept only for a run that did',
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();
/** 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()
.get(
'/',
@@ -385,7 +393,7 @@ export namespace SessionApi {
tags: ['Session'],
summary: 'Report where a run has got to',
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: {
200: {
content: { 'application/json': { schema: Result(Session.Info) } },
@@ -400,12 +408,24 @@ export namespace SessionApi {
validator('json', StateReport),
async (c) => {
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({
id: c.req.valid('param').id,
machineId: Actor.machineID,
state: body.state,
claimToken: body.claimToken,
errorMessage: body.errorMessage ?? null
errorMessage: body.errorMessage ?? null,
endReason: body.endReason ?? null
});
switch (result.outcome) {
+102 -15
View File
@@ -4,7 +4,10 @@
# This file is the source of what that URL serves, kept in the public
# repository so anyone about to pipe it into a shell can read it first.
#
# curl -fsSL https://api.nestri.io/install.sh | sudo sh -s -- <install-token>
# bash <(curl -fsSL https://api.nestri.io/install.sh) <install-token>
# bash <(curl -fsSL https://api.nestri.io/install.sh) -u
#
# Piping it works the same way: `curl -fsSL … | sh -s -- -u`.
#
# What it does, in order: check this is 64-bit Linux, ask where box images
# should live, download the host agent for this platform, verify it against the
@@ -14,10 +17,16 @@
#
# It runs as root because the agent does: each box's VMM is jailed under a uid
# of its own, which only root can give it, and a box's network needs
# CAP_NET_ADMIN. The agent keeps its state in /var/lib/nestri.
# CAP_NET_ADMIN. The agent keeps its state in /var/lib/nestri. Started as
# anyone else, it asks sudo for root once, before it changes anything.
#
# The token comes from the dashboard's Installation page. It registers one
# machine, works once and lapses after an hour.
#
# `-u` (`--update`) needs no token: on a machine that is already a host, it installs
# the current agent and restarts it, and the agent picks up whatever a new
# release changed about its images on its own. It touches nothing else -- the
# machine keeps its registration, its box store and its Steam sign-ins.
set -eu
@@ -32,8 +41,43 @@ STATE_DIR=/var/lib/nestri
say() { printf '%s\n' "$*" >&2; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
TOKEN="${1:-${NESTRI_INSTALL_TOKEN:-}}"
[ -n "$TOKEN" ] || die "no install token. Copy the full command from the dashboard's Installation page."
usage() {
cat >&2 <<USAGE
Nestri host installer.
bash <(curl -fsSL $API/install.sh) <install-token> make this machine a host
bash <(curl -fsSL $API/install.sh) -u update the host agent here
-u, --update install the current host agent on a machine that is already a host
-h, --help this
USAGE
}
MODE=install
TOKEN="${NESTRI_INSTALL_TOKEN:-}"
while [ $# -gt 0 ]; do
case "$1" in
-u|--update) MODE=update ;;
-h|--help) usage; exit 0 ;;
--) ;;
-*) usage; die "unknown option $1" ;;
*) TOKEN="$1" ;;
esac
shift
done
if [ "$MODE" = install ] && [ -z "$TOKEN" ]; then
usage
die "no install token. Copy the full command from the dashboard's Installation page."
fi
# --- fetch ------------------------------------------------------------------
if command -v curl >/dev/null 2>&1; then
get() { curl -fsSL "$1" -o "$2"; }
elif command -v wget >/dev/null 2>&1; then
get() { wget -qO "$2" "$1"; }
else
die "need curl or wget"
fi
# --- platform ---------------------------------------------------------------
[ "$(uname -s)" = Linux ] || die "a host has to run Linux (with KVM); this is $(uname -s)."
@@ -41,7 +85,46 @@ case "$(uname -m)" in
x86_64|amd64) target=x86_64-unknown-linux-gnu ;;
*) die "no host build for $(uname -m) yet." ;;
esac
[ "$(id -u)" -eq 0 ] || die "run this as root: pipe it into \`sudo sh -s -- <token>\`, as the dashboard shows."
# --- root ------------------------------------------------------------------
# Asked for once, here, before anything changes. A script read from a pipe or
# from `bash <(curl …)` has no file sudo could run again -- the descriptor it
# came on is this shell's, and sudo starts a fresh one -- so this fetches the
# same script to a private file and runs that as root, with the same mode and
# token. Only the settings somebody actually set are carried across.
if [ "$(id -u)" -ne 0 ]; then
command -v sudo >/dev/null 2>&1 || die "this needs root and there is no sudo here. Run it as root."
self="$(mktemp)"
get "$API/install.sh" "$self" || die "could not fetch $API/install.sh to run it as root"
if [ "$MODE" = update ]; then set -- -u; else set -- "$TOKEN"; fi
say "The host agent runs as root, so this needs sudo."
exec sudo env NESTRI_SELF="$self" NESTRI_API="$API" \
${NESTRI_HOST_VERSION:+NESTRI_HOST_VERSION="$NESTRI_HOST_VERSION"} \
${NESTRI_BIN_DIR:+NESTRI_BIN_DIR="$NESTRI_BIN_DIR"} \
${NESTRI_BOX_STORE:+NESTRI_BOX_STORE="$NESTRI_BOX_STORE"} \
sh "$self" "$@"
fi
# The copy fetched above, now open as this shell's script: removing the name
# leaves what is being read intact and leaves nothing behind.
if [ -n "${NESTRI_SELF:-}" ]; then rm -f "$NESTRI_SELF"; fi
# --- update: only where there is something to update -------------------------
# Checked before anything is downloaded, so a mistaken run costs nothing.
if [ "$MODE" = update ]; then
# Registered means the agent has a machine identity. Without one there is
# nothing to update, and installing would need the token this skips.
[ -f "$STATE_DIR/machine.json" ] || die "this machine is not a Nestri host yet, so there is nothing to update. Install it with the command from the dashboard's Installation page."
# Updating restarts the agent, and the agent stopping stops every box. A
# session somebody is playing would end in the middle, so wait for it.
if pgrep -x nesbox >/dev/null 2>&1; then
die "a box is running on this machine right now, and updating would stop it. Run this again when nobody is playing."
fi
current="$("$BIN_DIR/nestri-host" --version 2>/dev/null | awk '{print $NF}')" || current=""
if [ "$current" = "$VERSION" ]; then
say "The host agent is already $VERSION. Nothing to do."
exit 0
fi
say "Updating the host agent from ${current:-an unknown version} to $VERSION…"
fi
# --- an install from before the agent ran as root ---------------------------
# Hosts used to run the agent as the user who installed it, from a state
@@ -67,22 +150,14 @@ if [ -n "$OLD_STATE" ] && [ -f "$OLD_STATE"/onboarding.json ] && [ ! -e "$STATE_
say "Copied $OLD_STATE to $STATE_DIR."
fi
# --- fetch ------------------------------------------------------------------
if command -v curl >/dev/null 2>&1; then
get() { curl -fsSL "$1" -o "$2"; }
elif command -v wget >/dev/null 2>&1; then
get() { wget -qO "$2" "$1"; }
else
die "need curl or wget"
fi
# --- where box images go ----------------------------------------------------
# Their own device, xfs or ext4, and never `/`: box images are large, and a
# filled root filesystem takes the whole machine down with it. The agent's
# preflight checks this again; asking here is so the default is a good guess.
# An update keeps the store the machine already has, so it is not asked.
tty_ok() { [ -e /dev/tty ] && (exec 3</dev/tty) 2>/dev/null; }
BOX_STORE="${NESTRI_BOX_STORE:-}"
if [ -z "$BOX_STORE" ]; then
if [ "$MODE" = install ] && [ -z "$BOX_STORE" ]; then
guess="$(df -P -T -x tmpfs -x devtmpfs -x overlay 2>/dev/null \
| awk 'NR>1 && ($2=="xfs"||$2=="ext4") && $7!="/" && $7!~/^\/(boot|efi)/ {print $5, $7}' \
| sort -rn | awk 'NR==1 {print $2}')"
@@ -128,6 +203,18 @@ mv "$TMP/$ASSET" "$BIN_DIR/nestri-host"
say "Installed $BIN_DIR/nestri-host"
say ""
# --- update: restart on the new binary, and stop -----------------------------
# No onboarding: the machine is registered and configured already. The agent
# reads what its new release needs when it starts -- a raised image layout
# makes it pull the guest and its jail again -- so restarting is the whole of
# it.
if [ "$MODE" = update ]; then
systemctl restart nestri-host.service || die "the new agent is installed, but restarting nestri-host failed. See: journalctl -u nestri-host"
say "Updated to $("$BIN_DIR/nestri-host" --version 2>/dev/null | awk '{print $NF}'). If this release changed the box images, the agent is downloading them now:"
say " journalctl -fu nestri-host"
exit 0
fi
# --- upgrade ----------------------------------------------------------------
# On a host that already runs the agent, this is an upgrade: the agent holds
# the host lock, and onboarding refuses to race it. Stopped here, and started
+5 -1
View File
@@ -146,7 +146,11 @@ describe('the line for a full host', () => {
await Session.setState({ id: playing.run.id, state: 'ended' });
expect((await h.jobs()).map((j) => j.sessionId)).toEqual([here.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 () => {
+113 -2
View File
@@ -160,11 +160,13 @@ describe('POST /session', () => {
state: 'requested',
tier: null,
ticket: null,
timeClaimed: null,
timeStarted: null,
timeStopped: null,
timeStopRequested: null,
trial: false,
errorMessage: null
errorMessage: null,
endReason: null
});
expect(body.data.id.startsWith('ses_')).toBe(true);
});
@@ -478,7 +480,11 @@ describe('POST /session/:id/state', () => {
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
});
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 });
expect(((await jobs.json()) as any).data).toEqual([]);
@@ -638,6 +644,106 @@ describe('POST /session/:id/state', () => {
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 () => {
const s = await scene('route-claim-person', 5538);
const { body } = await requestSession(s);
@@ -721,6 +827,7 @@ describe('POST /session/:id/ticket', () => {
const data = ((await read.json()) as any).data;
expect(Object.keys(data).sort()).toEqual([
'boxId',
'endReason',
'errorMessage',
'gameId',
'id',
@@ -729,6 +836,7 @@ describe('POST /session/:id/ticket', () => {
'state',
'ticket',
'tier',
'timeClaimed',
'timeStarted',
'timeStopRequested',
'timeStopped',
@@ -901,6 +1009,9 @@ describe('POST /session/:id/stop', () => {
const out = (await res.json()) as any;
expect(out.data.state).toBe('ended');
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 });
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,
"tag": "0025_run_queue",
"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,
// and obviously not one.
claimToken: '00000000000000000000000000000000',
timeClaimed: '2026-07-28T11:59:30.000Z',
timeStarted: '2026-07-28T12:00:00.000Z',
timeStopped: null,
errorMessage: null
errorMessage: null,
endReason: null
};
export const GameDownload = {
+78 -11
View File
@@ -56,6 +56,36 @@ export namespace Session {
*/
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
.object({
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',
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({
description: 'When the box actually started, not when the row appeared',
example: Examples.Session.timeStarted
@@ -109,6 +144,11 @@ export namespace Session {
description: 'Why it failed, when it did',
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({
description:
'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
* 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(
Info.pick({ id: true, state: true, errorMessage: true }),
Info.pick({ id: true, state: true, errorMessage: true, endReason: true }),
async (input) => {
const now = sql`now()`;
return Database.use(async (tx) => {
@@ -273,6 +314,10 @@ export namespace Session {
.set({
state: input.state,
errorMessage: input.state === 'failed' ? (input.errorMessage ?? null) : null,
endReason:
input.state === 'ended' || input.state === 'failed'
? (input.endReason ?? null)
: null,
...(input.state === 'live'
? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` }
: {}),
@@ -325,10 +370,14 @@ export namespace Session {
* the only mutual exclusion in this design. `ended` and `failed` are
* terminal, so their entries are empty rather than absent — a state with no
* 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'][]> = {
requested: ['starting'],
starting: ['live', 'failed'],
starting: ['live', 'ended', 'failed'],
live: ['ended', 'failed'],
ended: [],
failed: []
@@ -541,7 +590,8 @@ export namespace Session {
.set({
state: 'ended',
timeStopped: sql`now()`,
errorMessage: 'Left the queue'
errorMessage: 'Left the queue',
endReason: 'left_queue' satisfies EndReason
})
.where(
and(
@@ -712,7 +762,12 @@ export namespace Session {
// the same moment wins or loses cleanly, never both.
const [cancelled] = await tx
.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')))
.returning();
if (cancelled) return serialize(cancelled);
@@ -756,7 +811,8 @@ export namespace Session {
from: z.enum(SessionState.enumValues),
to: z.enum(SessionState.enumValues),
claimToken: ClaimToken,
errorMessage: Info.shape.errorMessage
errorMessage: Info.shape.errorMessage,
endReason: Info.shape.endReason
}),
async (input) => {
const now = sql`now()`;
@@ -772,9 +828,16 @@ export namespace Session {
state: input.to,
// Taken on the claim and never written again: a terminal row
// still records which attempt ran it, and a holder that goes
// back to null lets a settled claim be replayed.
...(isClaim ? { claimToken: input.claimToken } : {}),
// back to null lets a settled claim be replayed. The time is
// 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,
// 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'
? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` }
: {}),
@@ -889,7 +952,8 @@ export namespace Session {
machineId: z.string(),
state: z.enum(SessionState.enumValues),
claimToken: ClaimToken,
errorMessage: Info.shape.errorMessage
errorMessage: Info.shape.errorMessage,
endReason: Info.shape.endReason
}),
async (input): Promise<TransitionResult> => {
// One transaction, because "this run is live" and "the box under it
@@ -933,7 +997,8 @@ export namespace Session {
from: current.state,
to: input.state,
claimToken: input.claimToken,
errorMessage: input.errorMessage
errorMessage: input.errorMessage,
endReason: input.endReason
});
// The state read above is not the state written below, and the
// gap is where two agents race. Nothing moved means somebody
@@ -1084,11 +1149,13 @@ export namespace Session {
state: input.state as Info['state'],
tier: input.tier as Info['tier'],
ticket: input.ticket,
timeClaimed: input.timeClaimed?.toISOString() ?? null,
timeStarted: input.timeStarted?.toISOString() ?? null,
timeStopped: input.timeStopped?.toISOString() ?? null,
timeStopRequested: input.timeStopRequested?.toISOString() ?? null,
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),
/** Why it ended badly, when it did. */
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
* 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');
});
});
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();
});
});