Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,19 +195,33 @@ Coral appears in the product in exactly two places, and adding a third is a desi
decision rather than a styling one — raise it in review, do not put it in a diff:

1. **The wordmark** in the rail (`frontend/app/src/shell/AppShell.tsx`).
2. **The ingest progress bar's fill** (`Progress` in `frontend/ui-core/src/primitives/Feedback.tsx`).
2. **The `Progress` primitive's fill** (`frontend/ui-core/src/primitives/Feedback.tsx`) — the
ingest bar, the weight download's, and any bar that comes after them.

A progress bar is the one piece of chrome a person watches rather than reads, which is
where the coral buys attention instead of spending it. The styleguide shows a `brand`
swatch as well — a styleguide is where a value is inspected, not where it is used.

The second entry names the **primitive** rather than one of its callers, and the recorded
reason is why: it argues about what a progress bar *is*, not about which screen has one. A
rule phrased as *the ingest bar* would make the weight download's bar look like the third
site this list forbids, and the honest reading — one component, used twice — would have had
to be argued from scratch every time a bar appeared.

To check the invariant:

```
git grep -nE '\b(bg|text|border|ring|fill|stroke)-brand\b' -- frontend
```

Three hits is the expected state: the two above, plus the styleguide swatch.
Four lines match and **three of them are usages**: the two above, plus the styleguide
swatch. The fourth is the `--color-brand` comment in `frontend/ui-core/src/styles.css`,
which states this rule rather than applying it, and which says the same two sites this list
does.

Three stays three however many bars there are, because the fill lives inside the primitive
and a second caller adds no occurrence of its own — which is exactly what makes the
invariant checkable by this grep after the change above.

### One filled button per view

Expand Down
201 changes: 201 additions & 0 deletions frontend/app/e2e/inference.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* The Inference section in a real browser: a weight download somebody watches.
*
* ## Why this is not `inference.test.tsx`
*
* The claim is **recovery**, and recovery is a claim about a page that did not
* start the thing it is showing. jsdom can assert that a component renders a prop;
* only a browser can throw the whole application away — the React tree, the query
* cache, every closure that might have been holding a job id — and prove that what
* comes back still knows a transfer is running. `page.reload()` is the assertion,
* and there is no jsdom equivalent of it.
*
* It is also where the *poll* is real: the bar below moves because a timer fired
* and an answer changed, with nothing clicked after the screen opened.
*
* What is **not** here is the counting half — *and it stops asking once nothing is
* moving*. That is a claim that nothing happens over an interval, and the only way
* to make it in a browser is to wait on a clock, which `tests/scripts/e2e_discipline`
* forbids for the reason it gives. It is asserted in `inference.test.tsx`, which
* can count requests without a browser scheduler deciding the outcome.
*
* ## The wire is stubbed and the screen is not
*
* `page.route` answers `/api/inference/connections` from a mutable fixture, so the
* bodies are what the server would send — `_wire.ts`'s rule, and the generated
* runtime checks enforce it: a row missing a required field is rejected before the
* screen renders, which is exactly what that gate is for.
*/

import { expect, test, type Page } from "@playwright/test";

const CONNECTION = "22222222-2222-4222-8222-222222222222";
const JOB = "44444444-4444-4444-8444-444444444444";

const GIGABYTE = 1_000_000_000;

interface Download {
readonly state: string;
readonly bytes_done: number;
readonly bytes_total: number | null;
readonly error?: string | null;
}

/** What the server answers for one local connection, in the state a test wants. */
function connection(setup: "not_set_up" | "ready", download: Download | null): unknown {
return {
id: CONNECTION,
name: "sam2-local",
connection_type: "local",
model_id: "facebook/sam2.1-hiera-base-plus",
model_revision: "b73207",
device: "cuda",
precision: "fp16",
endpoint_url: null,
setup_state: setup,
allowed_actions: ["download_weights", "update", "delete"],
capabilities: setup === "ready" ? ["point_suggest"] : [],
download:
download === null
? null
: { job_id: JOB, error: null, ...download },
created_at: "2026-08-08T00:00:00Z",
updated_at: "2026-08-08T00:00:00Z",
};
}

/**
* A workspace whose one connection is whatever `next` last returned.
*
* Mutable rather than frozen, and derived rather than replayed, for the reason
* `refactor-protocol` states about doubles: a stub that answers from a script
* makes a test assert against its own fixture, and a stub whose answer depends on
* registration order makes it assert against the order too. `next` is asked on
* every request, so what a test changes is the *workspace*, and the screen reads
* it the way it reads a server.
*/
async function serveApi(page: Page, next: () => unknown): Promise<void> {
await page.route("**/api/session", (route) => route.fulfill({ json: { issued: false } }));
await page.route("**/api/inference/connections*", (route) =>
route.fulfill({ status: 200, json: { items: [next()], total: 1 } }),
);
await page.route("**/api/projects**", (route) =>
route.fulfill({ status: 200, json: { items: [], total: 0 } }),
);
}

async function openInference(page: Page): Promise<void> {
await page.goto("/");
await page.getByTestId("token-input").fill("a-token");
await page.getByTestId("token-submit").click();
await page.getByTestId("rail-inference").click();
await expect(page.getByTestId("inference-screen")).toBeVisible();
}

test("a page that never started the download still shows it", async ({ page }) => {
// The shipped bug, in the one shape that could not be tested without a browser:
// the job id lived in a component, so only the mount that pressed the button
// could see the transfer. Everything else — a reload, a second tab, a colleague
// — got `Not set up` beside a download that was still running.
await serveApi(page, () =>
connection("not_set_up", { state: "running", bytes_done: 0.4 * GIGABYTE, bytes_total: 1.6 * GIGABYTE }),
);
await openInference(page);

await expect(page.getByTestId("download-progress-prose")).toHaveText("400.0 MB of 1.6 GB · 25%");
await expect(page.getByTestId("download-bar")).toHaveAttribute("aria-valuenow", "25");

// Throw the application away. Nothing survives this that a client is holding.
await page.reload();
await expect(page.getByTestId("inference-screen")).toBeVisible();

await expect(page.getByTestId("download-progress-prose")).toHaveText("400.0 MB of 1.6 GB · 25%");
await expect(page.getByTestId("download-bar")).toHaveAttribute("aria-valuenow", "25");
});

test("a transfer left running is where it got to when you come back", async ({ page }) => {
// Navigating away is the ordinary way somebody loses sight of a download, and
// the poll that was watching it goes with the screen. What comes back is read
// from the wire, so it shows the *current* number rather than the last one this
// browser happened to see.
let done = 0.4 * GIGABYTE;
await serveApi(page, () =>
connection("not_set_up", { state: "running", bytes_done: done, bytes_total: 1.6 * GIGABYTE }),
);
await openInference(page);
await expect(page.getByTestId("download-progress-prose")).toContainText("25%");

await page.getByTestId("rail-projects").click();
await expect(page.getByTestId("inference-screen")).toHaveCount(0);
// The worker keeps going, because nothing about it was ever this browser's.
done = 1.2 * GIGABYTE;

await page.getByTestId("rail-inference").click();
await expect(page.getByTestId("download-progress-prose")).toHaveText("1.2 GB of 1.6 GB · 75%");
});

test("the bar moves on the poll alone, and stops being a bar when it lands", async ({ page }) => {
// Nothing is clicked after the screen opens. Every number below arrives because
// a timer fired, a request went out, and the answer changed — which is the one
// part of this feature that only a browser can be asked about.
let done = 0.8 * GIGABYTE;
let settled = false;
await serveApi(page, () =>
settled
? connection("ready", {
state: "succeeded",
bytes_done: 1.6 * GIGABYTE,
bytes_total: 1.6 * GIGABYTE,
})
: connection("not_set_up", {
state: "running",
bytes_done: done,
bytes_total: 1.6 * GIGABYTE,
}),
);
await openInference(page);
await expect(page.getByTestId("download-progress-prose")).toContainText("50%");

done = 1.4 * GIGABYTE;
await expect(page.getByTestId("download-progress-prose")).toContainText("88%");

settled = true;
await expect(page.getByTestId("connection-status")).toContainText("Ready");
// The success treatment is the row's own status; the bar goes with the transfer.
await expect(page.getByTestId("download-progress")).toHaveCount(0);
});

test("arriving after a transfer finished shows the row and no bar", async ({ page }) => {
// The record stays on the connection once the job settles — it answers *what
// happened last time* — and a settled record is not something to draw a bar for.
await serveApi(page, () =>
connection("ready", {
state: "succeeded",
bytes_done: 1.6 * GIGABYTE,
bytes_total: 1.6 * GIGABYTE,
}),
);
await openInference(page);

await expect(page.getByTestId("connection-status")).toContainText("Ready");
await expect(page.getByTestId("download-progress")).toHaveCount(0);
await expect(page.getByTestId("download-error")).toHaveCount(0);
});

test("a transfer that failed while nobody was watching still says why", async ({ page }) => {
await serveApi(page, () =>
connection("not_set_up", {
state: "failed",
bytes_done: 0.3 * GIGABYTE,
bytes_total: 1.6 * GIGABYTE,
error: "could not fetch facebook/sam2.1-hiera-base-plus at b73207: the connection was lost",
}),
);
await openInference(page);

const shown = page.getByTestId("download-error");
await expect(shown).toContainText("the connection was lost");
await expect(shown).toContainText("still Not set up");
// The remedy is the action the connection declares, not a second control.
await expect(page.getByTestId("download-weights")).toBeEnabled();
});
50 changes: 50 additions & 0 deletions frontend/ui-core/src/data/inferenceQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,32 @@ export type ConnectionType = components["schemas"]["ConnectionType"];
export type ConnectionSetupState = components["schemas"]["ConnectionSetupState"];
export type Precision = components["schemas"]["Precision"];
export type DownloadSizeOut = components["schemas"]["DownloadSizeOut"];
export type WeightDownload = components["schemas"]["WeightDownloadOut"];

/**
* Whether this connection's weights are arriving right now.
*
* Named for the *live* states rather than for the settled ones, which is the
* opposite of `usePollingQuery`'s rule and is deliberate: there, the terminal
* states are the enumerated ones and a predicate written the other way round
* would silently keep polling something added later. Here the live pair is what
* the domain enumerates — a job is `queued` then `running` and everything else is
* an ending — so this is the set that cannot grow without a kernel change.
*/
export function isDownloading(connection: Connection): boolean {
const state = connection.download?.state;
return state === "queued" || state === "running";
}

/**
* How often the connection list is re-read while a transfer is in flight.
*
* The same two seconds `DEFAULT_POLL_MS` uses, and for its stated reason: under
* the threshold where a person decides nothing is happening, and slow enough not
* to compete with the work being measured. The worker writes progress at most
* twice this often, so a faster poll would read the same number twice.
*/
export const DOWNLOAD_POLL_MS = 2_000;

export const inferenceKeys = {
connections: () => ["inference", "connections"] as const,
Expand All @@ -116,6 +142,22 @@ export const inferenceKeys = {
* Unfiltered on purpose: "none configured" and "one configured but its weights
* are not here" are different sentences with different remedies, and a filtered
* list would make them look identical.
*
* ## Why this polls, and only sometimes
*
* A weight download is a background job the server owns, and the wire says how
* far it has got — so a screen showing one has to re-read something. It re-reads
* *this*, because the connection is where the download lives: nothing has to
* remember a job id, and a page that arrives mid-transfer sees it on its first
* fetch. Recovery after a navigation, a reload or a fresh tab is then a property
* of the shape rather than a feature anybody wrote.
*
* The interval is a function of the answer, which is what stops it: `false` the
* moment no row reports a live transfer, and `false` before the first one lands.
* That last part is where this departs from `usePollingQuery`, which keeps asking
* when it has no data — the right rule for a job somebody is waiting on, and the
* wrong one here, because this list is also the annotator's read and a broken
* endpoint would be polled forever behind a screen that never asked for one.
*/
export function useConnections(enabled = true): UseQueryResult<ConnectionPage, Error> {
const client = useApiClient();
Expand All @@ -124,6 +166,14 @@ export function useConnections(enabled = true): UseQueryResult<ConnectionPage, E
enabled,
queryFn: async () =>
unwrap(await client.GET("/inference/connections", {}), checkListInferenceConnections),
refetchInterval: (query) => {
const page = query.state.data;
if (page === undefined) return false;
return page.items.some(isDownloading) ? DOWNLOAD_POLL_MS : false;
},
// A download started and left to run is the ordinary way this is used, so a
// backgrounded tab must not come back to a bar frozen where it was hidden.
refetchIntervalInBackground: true,
});
}

Expand Down
Loading
Loading