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
15 changes: 15 additions & 0 deletions apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,21 @@ the onboarding forward and reveals the console in a circle growing from the
pressed button. With reduced motion the orbits hold their places, the grid is a
still frame, the request is a plain block and no transition runs.

### Sandbox setup
Setting up hosted sandboxes, and changing the provider or resources in maintenance,
is a set of pages inside the Nodes page, one decision each: where sandboxes run (own machines
or E2B), then the backend (Docker or microsandbox, compared behind a help tip) or
the E2B account, then the size of each sandbox (three presets), then a review.
Choices are large cards that advance on a click; short indigo dashes show the
progress; pages slide and blur across. The review states where sandboxes run,
the size, the Runtime (taken from this console's distribution manifest) and the
Core address, asking for it only when the console's own address cannot serve.
Advanced settings, one link away, hold the complete form: resources, the Core
address, the Runtime release and the E2B template. A change keeps the saved size
and Runtime while the backend stays the same (a saved size outside the presets is
offered as Current); another backend starts from its standard size and this
console's Runtime, and E2B always needs its key again. Rules sit behind help tips.

### Loading and motion
The console has no spinners and no "Loading…" lines. Reads are cached (TanStack
Query) and prefetched on navigation hover, so revisits show data at once and
Expand Down
8 changes: 5 additions & 3 deletions apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,11 @@ workbench.
Creator column naming the creating key. Detail pages show the resource's facts
and offer Copy and Delete.
- **Platform**: Projects and keys (projects, their assets and usage, named keys,
write history), Nodes (sandbox deployment, the node list with each node's
capacity, host figures and allocations, enrollment and removal), System (startup
configuration and the sandbox deployment).
write history), Nodes (sandbox setup as pages — where sandboxes run, the
backend or E2B account, the size of each sandbox, a review, and advanced settings
with the complete form — then the node list with each node's capacity, host
figures and allocations, enrollment and removal), System (startup configuration
and the sandbox deployment, including each sandbox's size and the Runtime).
- **First run**: after the administrator account is created and while no project
exists, full-screen steps outside the shell create the first project (default name
`Default`) and its first key, show the plaintext once with an example request,
Expand Down
8 changes: 4 additions & 4 deletions apps/web/e2e/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`;
const web = `http://127.0.0.1:${process.env.AGENTS_WEB_PORT ?? 4174}`;

/** Fresh fixture state: "setup" (no administrator yet), "login" or "authenticated". */
export async function resetFixture(request: APIRequestContext, auth: "setup" | "login" | "authenticated" = "authenticated", options: { fresh?: boolean } = {}) {
await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}`);
export async function resetFixture(request: APIRequestContext, auth: "setup" | "login" | "authenticated" = "authenticated", options: { fresh?: boolean; sandbox?: "configured" | "none" } = {}) {
await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}`);
}

/** Opens the console already signed in, in English. */
export async function openConsole(page: Page, request: APIRequestContext, hash = "overview") {
await resetFixture(request, "authenticated");
export async function openConsole(page: Page, request: APIRequestContext, hash = "overview", options: { sandbox?: "configured" | "none" } = {}) {
await resetFixture(request, "authenticated", options);
await page.context().addCookies([{ name: "core_console", value: "fixture-session", url: web }]);
await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en"));
await page.goto(`/#${hash}`);
Expand Down
26 changes: 23 additions & 3 deletions apps/web/e2e/fixture-console.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,16 @@ const port = Number(process.env.AGENTS_FIXTURE_PORT ?? 18092);
const SESSION_COOKIE = "core_console=fixture-session";

let state;
function reset(mode = "setup", fresh = false) {
const hex = (c) => c.repeat(64);
/** The Runtime release this fixture's distribution manifest describes. */
const release = { source_commit: "c0ffee".padEnd(40, "0"), image_id: `sha256:${hex("1")}`, image_manifest_digest: `sha256:${hex("2")}`, microsandbox_ref: `parsar-core-runtime@sha256:${hex("3")}`, runtime_sha256: hex("4"), firmware_sha256: hex("5") };
const manifest = { platform: "linux/amd64", source_commit: release.source_commit, images: { runtime: release.image_id }, image_manifest_digests: { runtime: release.image_manifest_digest }, runtime_ref: release.microsandbox_ref, microsandbox: { runtime_sha256: release.runtime_sha256, firmware_sha256: release.firmware_sha256 }, artifacts: {} };

function configuredDeployment() {
return { installation_id: "7f3c2a90-fixture", provider: "docker", core_url: `http://127.0.0.1:${port}`, maintenance: false, owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: release }, specification_digest: "fixture" };
}

function reset(mode = "setup", fresh = false, sandbox = "configured") {
const base = buildDemo();
const now = Math.floor(Date.now() / 1000);
const resources = buildResources(now, base.agents, base.sessions);
Expand All @@ -25,6 +34,8 @@ function reset(mode = "setup", fresh = false) {
...base, resources, admin,
auth: { mode, username: mode === "authenticated" ? "admin" : null, password: mode === "setup" ? null : "correct horse battery" },
violations: [], writes: [], failNext: null, nextId: 1,
// "none": the deployment is not configured yet, so the Nodes page offers setup.
deployment: sandbox === "none" ? null : configuredDeployment(),
};
}
reset();
Expand Down Expand Up @@ -189,8 +200,15 @@ function adminRead(response, path, url) {
}

async function sandboxRoute(request, response, path) {
if (path === "/deployment" && request.method === "POST") {
const input = await body(request);
if (state.deployment) return error(response, 409, "The sandbox deployment is already configured.", "sandbox_deployment_conflict");
if (!input.resources || (input.provider !== "e2b" && !input.runtime)) return error(response, 400, "resources and runtime are required.", "invalid_sandbox_configuration");
state.deployment = { ...configuredDeployment(), provider: input.provider, core_url: input.core_url, mode: input.provider === "e2b" ? "direct" : "nodes", specification: { resources: input.resources, ...(input.runtime ? { runtime: input.runtime } : {}) } };
return send(response, 201, state.deployment);
}
if (path === "/deployment") {
return send(response, 200, { installation_id: "7f3c2a90-fixture", provider: "docker", core_url: `http://127.0.0.1:${port}`, maintenance: false, owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: state.allocations.length, pending: 0 } });
return send(response, 200, state.deployment ?? { installation_id: "7f3c2a90-fixture", provider: "", core_url: `http://127.0.0.1:${port}`, maintenance: false, owner_epoch: 3, generation: 0, mode: "", resources: { allocations: 0, pending: 0 } });
}
if (path === "/nodes") return send(response, 200, { data: state.nodes });
if (path === "/enrollment-tokens" && request.method === "POST") {
Expand All @@ -211,7 +229,7 @@ async function sandboxRoute(request, response, path) {
async function fixtureRoute(request, response, url) {
if (url.pathname === "/__fixture/health") return send(response, 200, { ok: true });
if (url.pathname === "/__fixture/reset" && request.method === "POST") {
reset(url.searchParams.get("auth") ?? "setup", url.searchParams.get("projects") === "none");
reset(url.searchParams.get("auth") ?? "setup", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured");
return send(response, 200, { ok: true });
}
if (url.pathname === "/__fixture/fail-next" && request.method === "POST") {
Expand All @@ -226,6 +244,8 @@ http.createServer(async (request, response) => {
const url = new URL(request.url, `http://127.0.0.1:${port}`);
try {
if (url.pathname.startsWith("/__fixture/")) return await fixtureRoute(request, response, url);
// The console service serves its distribution manifest to anyone, as nodes download it.
if (url.pathname === "/node-install/manifest.json") return send(response, 200, manifest);
if (url.pathname === "/v1" || url.pathname.startsWith("/v1/")) {
state.violations.push(`${request.method} ${url.pathname}`);
return error(response, 404, "The console does not serve /v1.");
Expand Down
61 changes: 43 additions & 18 deletions apps/web/e2e/nodes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,22 @@ test("prepares a one-time node command and removes a node after confirmation", a
await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("edge-03");
});

test("preserves saved resources and Runtime, and clears provider-specific fields on switching", async ({ page, request }) => {
test("sets up own-machine sandboxes page by page, with the Runtime from the distribution", async ({ page, request }) => {
await openConsole(page, request, "nodes", { sandbox: "none" });
await expect(page.getByRole("heading", { name: "Where should sandboxes run?" })).toBeVisible();
await page.getByRole("button", { name: "Own machines" }).click();
await page.getByRole("button", { name: "Docker" }).click();
await page.getByRole("button", { name: /^Standard/ }).click();
await expect(page.getByRole("heading", { name: "Review and save" })).toBeVisible();
await page.getByLabel("Core address").fill("https://core.example.com");
await page.getByRole("button", { name: "Save configuration" }).click();

// The saved specification carries the Runtime read from the console's manifest.
await expect(page.getByText("c0ffee000000")).toBeVisible();
await expect(page.getByRole("button", { name: "Add node" })).toBeVisible();
});

test("keeps the saved size and Runtime for the same backend, and starts another from its defaults", async ({ page, request }) => {
const runtime = { source_commit: "0".repeat(40), image_id: `sha256:${"a".repeat(64)}`, image_manifest_digest: `sha256:${"b".repeat(64)}`,
microsandbox_ref: `parsar-core-runtime@sha256:${"b".repeat(64)}`, runtime_sha256: "c".repeat(64), firmware_sha256: "d".repeat(64) };
const current = { resources: { cpus: 7, memory_mib: 8192 }, runtime };
Expand All @@ -30,29 +45,39 @@ test("preserves saved resources and Runtime, and clears provider-specific fields
await route.fulfill({ json: deployment });
});
await page.route("**/core/v1/sandbox/nodes", (route) => route.fulfill({ json: { data: [] } }));
await page.route("**/node-install/manifest.json", (route) => route.fulfill({ json: { platform: "linux/amd64", source_commit: "e".repeat(40),
images: { runtime: `sha256:${"f".repeat(64)}` }, image_manifest_digests: { runtime: `sha256:${"1".repeat(64)}` },
runtime_ref: `parsar-core-runtime@sha256:${"1".repeat(64)}`, microsandbox: { runtime_sha256: "2".repeat(64), firmware_sha256: "3".repeat(64) } } }));
await openConsole(page, request, "nodes");
const save = page.getByRole("button", { name: "Save and stay in maintenance" });
const back = page.getByRole("button", { name: "Back" });

// The same backend keeps its saved size and Runtime.
await page.getByRole("button", { name: "Change provider or resources" }).click();
await page.getByLabel("Where to run sandboxes").selectOption("nodes");
await page.getByRole("combobox", { name: "Sandbox provider", exact: true }).selectOption("docker");
await page.getByText("Advanced sandbox resources", { exact: true }).click();
await expect(page.getByLabel("CPU cores per sandbox", { exact: true })).toHaveValue("7");
await page.getByRole("button", { name: "Save provider and stay in maintenance" }).click();
await page.getByRole("button", { name: "Own machines" }).click();
await page.getByRole("button", { name: "Docker" }).click();
await page.getByRole("button", { name: /^Current/ }).click();
await save.click();
await expect.poll(() => submitted?.resources).toEqual(current.resources);
expect(submitted?.runtime).toEqual(runtime);
expect(submitted?.specification).toBeUndefined();
await page.getByRole("combobox", { name: "Sandbox provider", exact: true }).selectOption("microsandbox");
await expect(page.getByLabel("CPU cores per sandbox", { exact: true })).toHaveValue("2");
await page.getByRole("button", { name: "Save provider and stay in maintenance" }).click();

// Another backend starts from its own size, with disks, and this console's Runtime.
await back.click();
await back.click();
await page.getByRole("button", { name: "microsandbox" }).click();
await page.getByRole("button", { name: /^Standard/ }).click();
await save.click();
await expect.poll(() => submitted?.provider).toBe("microsandbox");
expect(submitted?.resources).toEqual({ cpus: 2, memory_mib: 4096, root_disk_mib: 8192, environment_disk_mib: 8192 });
expect(submitted?.runtime).toMatchObject({ source_commit: "e".repeat(40) });
await page.getByLabel("Where to run sandboxes").selectOption("direct");
await page.getByLabel("E2B API key", { exact: true }).fill("fixture-private-key");
await page.getByLabel("Immutable Runtime template", { exact: true }).fill("template:94be54a1-138c-4f30-bc87-b13686272dbe");
await page.getByRole("button", { name: "Save provider and stay in maintenance" }).click();
expect(submitted?.runtime).toMatchObject({ source_commit: "c0ffee".padEnd(40, "0") });

// E2B needs its key again and takes no Runtime or disks.
await back.click();
await back.click();
await back.click();
await page.getByRole("button", { name: "E2B cloud" }).click();
await page.getByLabel("E2B API key").fill("fixture-private-key");
await page.getByLabel("Template build").fill("template:94be54a1-138c-4f30-bc87-b13686272dbe");
await page.getByRole("button", { name: "Next" }).click();
await page.getByRole("button", { name: /^Standard/ }).click();
await save.click();
await expect.poll(() => submitted?.provider).toBe("e2b");
expect(submitted?.resources).toEqual({ cpus: 2, memory_mib: 2048 });
expect(submitted?.runtime).toBeUndefined();
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/features/onboarding/onboarding.css
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@
color: var(--accent);
font-size: 12px;
font-weight: 600;
background: color-mix(in oklch, var(--accent) 10%, transparent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
border-radius: 999px;
}

Expand Down Expand Up @@ -415,7 +415,7 @@
height: 30px;
place-items: center;
color: var(--accent);
background: color-mix(in oklch, var(--accent) 8%, var(--surface));
background: color-mix(in srgb, var(--accent) 8%, var(--surface));
border-radius: var(--radius-control);
box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--accent) 18%, transparent);
}
Expand Down
15 changes: 12 additions & 3 deletions apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { useState } from "react";
import type { InitializeSandboxDeployment, SandboxDeployment } from "@agents-core-web/agents-client";
import { useTranslation } from "react-i18next";
import { HelpTip } from "../../components/console-ui";
import { formatBytes, MISSING } from "../../lib/format";
import { sandboxProviderLabel } from "../../lib/sandbox-labels";
import { SandboxSetup } from "./SandboxSetup";
import { SandboxSetupWizard } from "./SandboxSetupWizard";

export function SandboxDeploymentSettings({ deployment, disabled, fresh, error, onMaintenance, onUpdate, onRefresh }: {
deployment: SandboxDeployment;
Expand All @@ -17,6 +18,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, error,
const { t, i18n } = useTranslation("sandbox");
const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en";
const [changing, setChanging] = useState(false);
const spec = deployment.specification;
const clean = fresh && deployment.resources?.allocations === 0 && deployment.resources?.pending === 0;
return <section className="sandbox-provider-settings form-stack" aria-labelledby="sandbox-provider-heading">
<div className="sandbox-provider-title">
Expand All @@ -28,7 +30,8 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, error,
</div>
<dl className="sandbox-summary">
<div><dt>{t("Provider")}</dt><dd>{sandboxProviderLabel(deployment.provider, locale)}</dd></div>
{deployment.specification ? <><div><dt>{t("CPU cores per sandbox")}</dt><dd>{deployment.specification.resources.cpus}</dd></div><div><dt>{t("Memory per sandbox (MiB)")}</dt><dd>{deployment.specification.resources.memory_mib}</dd></div></> : null}
<div><dt>{t("Each sandbox")}</dt><dd>{spec ? `${t("{{cpus}} CPU · {{memory}}", { cpus: spec.resources.cpus, memory: formatBytes(spec.resources.memory_mib * 2 ** 20) })}${spec.resources.root_disk_mib ? ` · ${t("Root disk {{root}} · data disk {{data}}", { root: formatBytes(spec.resources.root_disk_mib * 2 ** 20), data: formatBytes((spec.resources.environment_disk_mib ?? 0) * 2 ** 20) })}` : ""}` : MISSING}</dd></div>
{spec?.runtime ? <div><dt>{t("Runtime")}</dt><dd><code title={spec.runtime.source_commit}>{spec.runtime.source_commit.slice(0, 12)}</code></dd></div> : null}
<div><dt>{t("Allocated resources")}</dt><dd>{deployment.resources?.allocations ?? t("Unknown state")}</dd></div>
<div><dt>{t("Pending environments")}</dt><dd>{deployment.resources?.pending ?? t("Unknown state")}</dd></div>
</dl>
Expand All @@ -44,7 +47,13 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, error,
<HelpTip>{t("Maintenance pauses new hosted placement. Clean up existing execution resources before changing provider; historical Sessions and results are preserved by the switch.")} {t("Stopped or offline resources, snapshots, uncertain creates and pending environments still block switching. Deleting a Session alone does not prove cleanup; Core must confirm both counts are zero.")}</HelpTip>
</p>
{!changing ? <button type="button" className="button outline" disabled={disabled || !clean} onClick={() => setChanging(true)}>{t("Change provider or resources")}</button> : <>
<SandboxSetup initialCoreUrl={deployment.core_url} disabled={disabled || !clean} switching savedProvider={deployment.provider} initialSpecification={deployment.specification} onInitialize={onUpdate} />
<SandboxSetupWizard
initialCoreUrl={deployment.core_url}
current={deployment.provider ? { provider: deployment.provider, specification: deployment.specification, e2bTemplate: deployment.e2b?.template } : undefined}
disabled={disabled || !clean}
switching
onSubmit={onUpdate}
/>
<span className="sandbox-provider-actions">
<button type="button" className="button outline" disabled={disabled} onClick={() => setChanging(false)}>{t("Cancel")}</button>
<HelpTip>{t("Saving retires old node identities and enrollment credentials. It does not migrate Sessions or resume placement automatically.")}</HelpTip>
Expand Down
Loading