feat(api): the session endpoint, and a claim that only one caller can win

A run of a box had core support and no HTTP surface. This adds both halves
of it: a person asks for a run and reads it back, and the host agent the box
is placed on is handed the work and reports what happened.

The access rule is the point. An agent may only see or touch a run whose box
is placed on its own hardware, and that is a `where` clause on every one of
the three agent endpoints rather than a check next to them — host credentials
are long-lived secrets sitting on hardware in somebody's home, so what one
leaking can reach has to be decided by the query. "No such run" and "not your
run" are the same refusal, so ids cannot be discovered by reporting states
at them.

`Session.setState` updated on the id alone, which means two agents polling
the same work both succeed and both start the same box. There is one host
today, which is exactly why that would have been built wrong and stayed
wrong. The state a run is moving out of is now part of the `where` clause,
so the database picks the winner; the loser gets a conflict rather than a
silent no-op. Three cases that look alike are kept apart: re-reporting a
state you already reported changes nothing and is not an error, a transition
that does not exist is refused with the run left where it was, and another
host reporting anything is forbidden.

Asking for a run makes no decision about where it happens — a box already
names its hardware, so the run inherits it by join. Placement therefore
gets an interface at box creation, where the decision actually is, with the
single-host case as its implementation and a deliberate refusal when there
is more than one candidate and no policy to choose with.

Tests cover the wire shape from both sides, the query scoping, the claim,
and the timestamp idempotence a run's billing rests on.
This commit is contained in:
Wanjohi
2026-09-04 18:57:08 +03:00
parent aaa1bbd0f4
commit bbe729e5c7
8 changed files with 1658 additions and 2 deletions

View File

@@ -5,6 +5,7 @@ import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { BoxState, BoxTable, BoxTier } from './box.sql.js';
import { Placement } from './placement.js';
/**
* A VM someone owns.
@@ -77,6 +78,29 @@ export namespace Box {
}
);
/**
* Create a box and let something else decide where it runs.
*
* The placement seam is here, at creation, and nowhere else: `machineId` is
* set once and every later question about which hardware a box — or a run
* of it — belongs to is answered by joining through this row. A caller that
* knows the host still uses `create`; a caller acting for a person does not
* know and must not guess, which is what this overload is for.
*/
export const createPlaced = async (
input: { id: string; userId: string; label: string; tier: Info['tier'] },
placer?: Placement.Placer
) => {
const machineId = await Placement.choose({ userId: input.userId, tier: input.tier }, placer);
return create({
id: input.id,
userId: input.userId,
machineId,
label: input.label,
tier: input.tier
});
};
export const fromID = fn(Info.shape.id, async (id) => {
return Database.use(async (tx) => {
return tx

View File

@@ -0,0 +1,90 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { Fixtures } from '../db/fixtures.js';
import { testDb } from '../db/test.js';
import { Identifier } from '../id.js';
import { Placement } from './placement.js';
import { Box } from './index.js';
const sql = testDb();
const createdUserIds: string[] = [];
async function newOwner(label: string) {
const o = await Fixtures.owner(label);
createdUserIds.push(o.userId);
return o;
}
afterAll(async () => {
if (createdUserIds.length > 0) {
await sql`delete from "box" where user_id in ${sql(createdUserIds)}`;
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
createdUserIds.length = 0;
}
});
describe('Placement', () => {
test('a box is placed when it is created, and the caller names no host', async () => {
const owner = await newOwner('place-one');
const machineId = await Fixtures.machine(owner, 'place-one-host');
// No `machineId` in the input: choosing the host is the placer's job,
// and the whole point of the interface is that the caller cannot do it.
const box = await Box.createPlaced({
id: Identifier.ascending('box'),
userId: owner.userId,
label: 'living room',
tier: 'sm'
});
expect(box.machineId).toBe(machineId);
});
test('nowhere to put it is an answer, not a crash', async () => {
const owner = await newOwner('place-none');
// `box.machineId` is notNull, so a placer with no candidate must refuse
// rather than hand back something the insert would reject.
await expect(
Placement.choose({ userId: owner.userId, tier: 'sm' })
).rejects.toThrow();
});
test('more than one candidate is refused rather than picked silently', async () => {
const owner = await newOwner('place-two');
await Fixtures.machine(owner, 'place-two-a');
await Fixtures.machine(owner, 'place-two-b');
// There is no policy for choosing between hosts yet. Inventing one here
// is how a placement decision ends up buried in the caller: the refusal
// is what keeps the choice in one replaceable place.
await expect(
Placement.choose({ userId: owner.userId, tier: 'sm' })
).rejects.toThrow();
});
test('the placer is swappable without touching box creation', async () => {
const owner = await newOwner('place-swap');
const machineId = await Fixtures.machine(owner, 'place-swap-host');
const asked: unknown[] = [];
const box = await Box.createPlaced(
{
id: Identifier.ascending('box'),
userId: owner.userId,
label: 'bedroom',
tier: 'lg'
},
async (input) => {
asked.push(input);
return machineId;
}
);
expect(box.machineId).toBe(machineId);
// The placer is told who the box is for and what size was asked for,
// which is the whole input a real scheduler needs.
expect(asked).toEqual([{ userId: owner.userId, tier: 'lg' }]);
});
});

View File

@@ -0,0 +1,73 @@
import z from 'zod';
import { ErrorCodes, VisibleError } from '../error.js';
import { Machine } from '../machine/index.js';
import { BoxTier } from './box.sql.js';
/**
* Deciding which host a box runs on.
*
* This is a seam and not an algorithm. `box.machineId` is set once, when the
* box is created, and everything downstream — a session, its job, the state
* reports that follow — reaches the right hardware by joining through the box.
* So there is exactly one moment where placement happens, and the value of
* naming it now is that a real scheduler replaces this file and nothing else.
*
* The wrong shape, and the tempting one, is to place a box when a *run* is
* requested. That spreads the decision across every caller that starts
* something and leaves nowhere to put a scheduler later.
*/
export namespace Placement {
export const Request = z.object({
userId: z.string().meta({ description: 'Who the box is for' }),
tier: z.enum(BoxTier.enumValues).meta({ description: 'The size that was asked for' })
});
export type Request = z.infer<typeof Request>;
/**
* Answers "which host should run this box?" with a machine id.
*
* Asynchronous and allowed to refuse: capacity is a real answer, and a
* placer that cannot honour a request must say so rather than return
* something the insert would reject — `box.machineId` is not nullable.
*/
export type Placer = (request: Request) => Promise<string>;
/**
* The implementation there is hardware for: place it on the caller's host.
*
* Deliberately refuses when the answer is not forced. With no host there is
* nothing to place on; with several there is a choice to make and no policy
* to make it with, and picking the first row would be a scheduling decision
* taken by accident and impossible to find later. Refusing keeps the choice
* in this one function.
*/
export const onlyHost: Placer = async (request) => {
const hosts = await Machine.listByOwner(request.userId);
if (hosts.length === 0) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'You have no registered host to run a box on'
);
}
if (hosts.length > 1) {
// Not a caller error: the request is fine and the system cannot yet
// answer it. An orchestrator is what closes this. todo(d-0048)
throw new VisibleError(
'internal',
ErrorCodes.Server.SERVICE_UNAVAILABLE,
'More than one host could run this box, and choosing between them is not supported yet'
);
}
return hosts[0]!.id;
};
/** Place a box, using `onlyHost` unless a caller supplies its own placer. */
export async function choose(request: Request, placer: Placer = onlyHost): Promise<string> {
return placer(Request.parse(request));
}
}