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
14 changes: 10 additions & 4 deletions apps/control-api/test/workbench.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,16 @@ while :; do sleep 1; done
expect(cancelling?.status).toBe("cancelling");

await expect
.poll(async () => {
const setup = await runtime.getSetup(started.setup_test_id, "test-operator");
return setup?.status;
})
.poll(
async () => {
const setup = await runtime.getSetup(
started.setup_test_id,
"test-operator",
);
return setup?.status;
},
{ timeout: 5_000 },
)
.toBe("cancelled");
expect(
(await runtime.logs(started.setup_test_id, "test-operator"))?.stdout,
Expand Down
21 changes: 16 additions & 5 deletions apps/control-web/e2e/control.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -950,7 +950,9 @@ for (const width of [1440, 390]) {
await page.clock.fastForward(125_000);
expect(await waffle.boundingBox()).toEqual(beforeFailure);
await expect(
waffle.getByRole("button", { name: "Retry", exact: true }),
page
.getByRole("region", { name: "Refresh status" })
.getByRole("button", { name: "Retry Trial progress", exact: true }),
).toBeVisible();
await expect(waffle.getByText(/● Stale/)).toBeVisible();
const unknown = page.getByRole("button", {
Expand Down Expand Up @@ -1888,7 +1890,7 @@ for (const cache of [0, null]) {
for (const width of [1440, 390]) {
test(`stable measured timing during delayed background polling at ${width}px`, async ({
page,
}) => {
}, testInfo) => {
await mockControl(page);
await page.setViewportSize({ width, height: 900 });
await page.clock.install();
Expand Down Expand Up @@ -1966,15 +1968,20 @@ for (const width of [1440, 390]) {
expect(text.split("\n").filter((line) => line.startsWith("Agent time:"))).toEqual([
"Agent time: 1m26s",
]);
const refresh = page.getByRole("region", { name: "Refresh status" });
const refreshBefore = await refresh.boundingBox();
const before = await matrix.boundingBox();
const squareBefore = await square.boundingBox();
await page.clock.runFor(30_001);
await expect.poll(() => progressReads).toBe(2);
expect(release).toBeDefined();
await expect(page.getByText("Refreshing…", { exact: true })).toHaveCount(0);
await expect(refresh.getByText("Refreshing…", { exact: true })).toBeVisible();
await expect(matrix.getByText(/Refreshing/)).toHaveCount(0);
expect(await refresh.boundingBox()).toEqual(refreshBefore);
expect(await matrix.boundingBox()).toEqual(before);
expect(await square.boundingBox()).toEqual(squareBefore);
await expect(square).toBeFocused();
await refresh.screenshot({ path: testInfo.outputPath("refresh-status.png") });
release?.();
await page.getByText("Agent Σ 12m30s · partial").locator("..").focus();
await expect(page.getByRole("tooltip")).toHaveText(
Expand Down Expand Up @@ -2078,7 +2085,9 @@ for (const coarse of [false, true]) {
);
await page.clock.runFor(60001);
await expect(
matrix.getByRole("button", { name: "Retry", exact: true }),
page
.getByRole("region", { name: "Refresh status" })
.getByRole("button", { name: "Retry Trial progress", exact: true }),
).toBeVisible();
expect(await matrix.boundingBox()).toEqual(before);
expect(await slot.boundingBox()).toEqual(slotBefore);
Expand Down Expand Up @@ -2556,7 +2565,9 @@ test("delayed observations render fresh between ticks and retry without moving s
);
await page.clock.runFor(30_001);
await expect(
matrix.getByRole("button", { name: "Retry", exact: true }),
page
.getByRole("region", { name: "Refresh status" })
.getByRole("button", { name: "Retry Trial progress", exact: true }),
).toBeVisible();
await expect(cell).toBeFocused();
await expect(page.getByRole("tooltip")).toContainText(
Expand Down
96 changes: 86 additions & 10 deletions apps/control-web/e2e/run-loading.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,17 @@ for (const width of [1440, 390]) {
failed &&
[base, `${base}/trials`, `${base}/progress`, "/api/v1/jobs"].includes(path)
) {
await route.fulfill({ status: 503, json: { error: "Synthetic unavailable" } });
await route.fulfill({
status: 503,
json: {
error: {
code: "synthetic_unavailable",
message: "Synthetic unavailable",
retry_at: "2026-09-11T12:30:00Z",
request_id: "synthetic-request",
},
},
});
return;
}
const responses: Record<string, unknown> = {
Expand Down Expand Up @@ -77,8 +87,62 @@ for (const width of [1440, 390]) {
});
await page.clock.install();
await page.goto(`/runs/${id}`);
await expect(page.getByText("Loading run details…")).toBeVisible();
await expect(page.getByText("Loading trials…")).toBeVisible();
const refresh = page.getByRole("region", { name: "Refresh status" });
const entry = (label: string) =>
refresh.getByRole("group", { name: `${label} refresh` });
const checkErrorDetails = async (stale: boolean) => {
const before = await refresh.boundingBox();
const following = await refresh.evaluate(
(element) => element.nextElementSibling?.getBoundingClientRect().y,
);
const summary = entry("Run details").getByText("Details", { exact: true });
await expect(summary).toHaveAccessibleName("Run details error details");
const disclosure = entry("Run details").locator("details");
await expect(disclosure).not.toHaveAttribute("open");
await summary.focus();
await page.keyboard.press("Enter");
await expect(disclosure).toHaveAttribute("open", "");
await expect(
disclosure.getByText(
stale
? "The latest refresh failed: Synthetic unavailable"
: "Synthetic unavailable",
{ exact: true },
),
).toBeVisible();
await expect(
disclosure.getByText("Code: synthetic_unavailable · HTTP 503"),
).toBeVisible();
const retryTime = await page.evaluate(() =>
new Date("2026-09-11T12:30:00Z").toLocaleTimeString(),
);
await expect(disclosure.getByText(`Retry after ${retryTime}.`)).toBeVisible();
await expect(disclosure.getByText("synthetic-request")).toBeVisible();
if (stale) await expect(disclosure.getByText("Showing saved data")).toBeVisible();
expect(await refresh.boundingBox()).toEqual(before);
expect(
await refresh.evaluate(
(element) => element.nextElementSibling?.getBoundingClientRect().y,
),
).toBe(following);
const panel = disclosure.locator("div").first();
await expect(panel).toHaveCSS("position", "absolute");
await expect(panel).toHaveCSS("background-color", "oklch(0.129 0.042 264.695)");
const bounds = await panel.boundingBox();
expect(bounds).not.toBeNull();
expect(bounds?.x).toBeGreaterThanOrEqual(0);
expect((bounds?.x ?? 0) + (bounds?.width ?? 0)).toBeLessThanOrEqual(width);
await page.keyboard.press("Escape");
await expect(disclosure).not.toHaveAttribute("open");
await expect(summary).toBeFocused();
await page.keyboard.press("Space");
await expect(disclosure).toHaveAttribute("open", "");
await page.keyboard.press("Space");
await expect(disclosure).not.toHaveAttribute("open");
expect(await refresh.boundingBox()).toEqual(before);
};
await expect(entry("Run details").getByText("Loading…")).toBeVisible();
await expect(entry("Trials").getByText("Loading…")).toBeVisible();
await expect(page.getByText("No trial result is available yet.")).toHaveCount(0);
releaseTrials();
await expect(page.getByText("No trial result is available yet.")).toBeVisible();
Expand All @@ -87,40 +151,52 @@ for (const width of [1440, 390]) {
pending = new Promise<void>((resolve) => {
release = resolve;
});
await expect(page.getByText("Loading trial progress…")).toBeVisible();
await expect(entry("Trial progress").getByText("Loading…")).toBeVisible();
await expect(page.getByText("0 tasks × 0 repeat slots")).toHaveCount(0);
await expect(page.getByText("Loading parent Jobs…")).toBeVisible();
await expect(entry("Parent Jobs").getByText("Loading…")).toBeVisible();
await expect(page.getByText("No parent Jobs are available.")).toHaveCount(0);
releaseJobs();
const shots = process.env.RUN_LOADING_SCREENSHOTS;
if (shots)
await page.screenshot({ path: `${shots}/${width}-initial.png`, fullPage: true });
held.clear();
release();
await expect(page.getByText("Loading trial progress…")).toHaveCount(0);
await expect(entry("Trial progress").getByText("Loading…")).toHaveCount(0);
held = new Set([base, `${base}/trials`, `${base}/progress`, "/api/v1/jobs"]);
pending = new Promise<void>((resolve) => {
release = resolve;
});
await page.clock.runFor(30_001);
for (const label of ["run details", "trials", "trial progress", "parent Jobs"]) {
await expect(page.getByText(`Refreshing ${label}…`)).toBeVisible();
for (const label of ["Run details", "Trials", "Trial progress", "Parent Jobs"]) {
await expect(entry(label).getByText("Refreshing…")).toBeVisible();
}
await expect(page.getByText("No trial result is available yet.")).toBeVisible();
await expect(page.getByText("7 / -", { exact: true })).toBeVisible();
if (shots)
await page.screenshot({ path: `${shots}/${width}-refresh.png`, fullPage: true });
const refreshingBounds = await refresh.boundingBox();
failed = true;
held.clear();
release();
await page.clock.runFor(15_000);
await expect(page.getByText("Showing saved data")).toHaveCount(4);
await expect(refresh.getByText("Refresh failed · saved data")).toHaveCount(4);
expect(await refresh.boundingBox()).toEqual(refreshingBounds);
await checkErrorDetails(true);
await expect(page.getByText("7 / -", { exact: true })).toBeVisible();
if (shots)
await page.screenshot({ path: `${shots}/${width}-error.png`, fullPage: true });
await page.reload();
await page.clock.runFor(15_000);
await expect(page.getByRole("alert").first()).toBeVisible();
await expect(refresh.getByText("Unavailable", { exact: true })).toHaveCount(3);
// Progress is not mounted until the initial run response succeeds.
await expect(entry("Trial progress").getByText("Waiting…")).toBeVisible();
await checkErrorDetails(false);
failed = false;
await entry("Run details")
.getByRole("button", { name: "Retry Run details" })
.click();
await expect(entry("Run details").getByText("Idle")).toBeVisible();
await expect(entry("Run details").locator("details")).toHaveCount(0);
await expect(page.getByText("No trial result is available yet.")).toHaveCount(0);
});
}
12 changes: 7 additions & 5 deletions apps/control-web/src/pages.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ConsolidatedRunRefresh, RunRefreshStatus } from "./run-refresh-status";
import { RunSectionQuery } from "./run-section-query";
import { useRunClock } from "./queries";
import { LaunchEstimate } from "./launch-pricing";
Expand Down Expand Up @@ -790,8 +791,9 @@ export function RunPage() {
const jobs = useJobs();
if (!run.data)
return (
<>
<ConsolidatedRunRefresh.Provider value={true}>
<PageHeader title="Run detail" description={runId} />
<RunRefreshStatus runId={runId} />
<RunSectionQuery label="run details" query={run}>
<Empty>Run not found.</Empty>
</RunSectionQuery>
Expand All @@ -801,21 +803,21 @@ export function RunPage() {
{trials.data ? <TrialsTable trials={trials.data} /> : null}
</RunSectionQuery>
</Card>
</>
</ConsolidatedRunRefresh.Provider>
);
const item = run.data;
const jobIds = new Set(item.state.parent_jobs.map((job) => job.id));
const runJobs = jobs.data?.filter(
(job) => job.run_id === item.record.run_id || jobIds.has(job.id),
);
return (
<>
<ConsolidatedRunRefresh.Provider value={true}>
<PageHeader
title="Run detail"
description={item.record.run_id}
action={<RunActions run={item} />}
/>
<RunSectionQuery label="run details" query={run} />
<RunRefreshStatus runId={runId} />
{item.presentation?.archived ? <Badge>Archived</Badge> : null}
{item.presentation_available === false ? (
<p role="status">
Expand Down Expand Up @@ -938,7 +940,7 @@ export function RunPage() {
<JsonDetails label="Complete Harbor job result" value={item.result} />
) : null}
</div>
</>
</ConsolidatedRunRefresh.Provider>
);
}

Expand Down
119 changes: 119 additions & 0 deletions apps/control-web/src/run-refresh-status.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { useQueryClient } from "@tanstack/react-query";
import { createContext, useCallback, useSyncExternalStore } from "react";
import { keys, useRunClock } from "./queries";
import { ErrorNotice } from "./ui";

/** Run detail owns fetch feedback; evidence freshness stays in its sections. */
export const ConsolidatedRunRefresh = createContext(false);

function RefreshEntry({
label,
queryKey,
now,
}: {
label: string;
queryKey: readonly string[];
now: number;
}) {
const client = useQueryClient();
const subscribe = useCallback(
(notify: () => void) => client.getQueryCache().subscribe(notify),
[client],
);
const state = useSyncExternalStore(subscribe, () => client.getQueryState(queryKey));
const fetching = state?.fetchStatus === "fetching";
const failed = state?.status === "error";
const updatedAt = state?.dataUpdatedAt ?? 0;
const age = Math.max(0, Math.floor((now - updatedAt) / 1_000));
const ageLabel = age < 60 ? `${age}s ago` : `${Math.floor(age / 60)}m ago`;
const status = fetching
? updatedAt
? "Refreshing…"
: "Loading…"
: failed
? updatedAt
? "Refresh failed · saved data"
: "Unavailable"
: updatedAt
? "Idle"
: "Waiting…";
return (
<fieldset className="min-w-0" aria-label={`${label} refresh`}>
<div className="truncate text-xs font-medium text-slate-300">{label}</div>
<div
role={failed ? "alert" : "status"}
className={`h-5 truncate text-xs ${failed ? "text-amber-400" : "text-slate-400"}`}
title={status}
>
{status}
</div>
<div className="flex h-5 items-center gap-2 text-xs text-slate-500">
<span
className="min-w-0 truncate tabular-nums"
title={updatedAt ? new Date(updatedAt).toISOString() : undefined}
>
{updatedAt ? `Received ${ageLabel}` : "No response yet"}
</span>
{failed ? (
<>
<button
type="button"
className="shrink-0 text-amber-400 underline disabled:opacity-50"
aria-label={`Retry ${label}`}
disabled={fetching}
onClick={() => void client.refetchQueries({ queryKey, exact: true })}
>
Retry
</button>
<details
className="shrink-0"
onKeyDown={(event) => {
if (event.key === "Escape") {
event.currentTarget.open = false;
event.currentTarget.querySelector("summary")?.focus();
}
}}
>
<summary
className="cursor-pointer rounded text-amber-400 underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-400"
aria-label={`${label} error details`}
>
Details
</summary>
<div className="absolute inset-x-4 top-full z-20 max-h-80 overflow-auto rounded-lg bg-slate-950 p-2 shadow-xl [overflow-wrap:anywhere]">
<ErrorNotice error={state.error} stale={updatedAt > 0} />
</div>
</details>
</>
) : null}
</div>
</fieldset>
);
}

export function RunRefreshStatus({ runId }: { runId: string }) {
const now = useRunClock();
return (
<section
aria-label="Refresh status"
className="relative mb-5 rounded-lg border border-slate-800 bg-slate-950/50 px-4 py-3"
>
<div className="mb-2 flex items-center justify-between gap-2 text-xs text-slate-500">
<h2 className="font-medium text-slate-300">Refresh status</h2>
<span title="Response age is not the age of Harbor evidence. Polling pauses in background tabs.">
Polls every 30s while visible
</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">
<RefreshEntry label="Run details" queryKey={keys.run(runId)} now={now} />
<RefreshEntry
label="Trial progress"
queryKey={["trial-progress", runId]}
now={now}
/>
<RefreshEntry label="Trials" queryKey={keys.trials(runId)} now={now} />
<RefreshEntry label="Parent Jobs" queryKey={keys.jobs} now={now} />
</div>
</section>
);
}
Loading