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
45 changes: 31 additions & 14 deletions apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -544,24 +544,38 @@ request runs.
and, for Docker, that the docker group is root-equivalent), and "No sudo on this
host?", with what the node's own user needs and the command without sudo. The
log command follows the command last copied; after the no-sudo one it adds the
system service's, for a root shell. Until the installation is read, a line says
it is being checked; a failed read, a loopback public URL, or a console without
the provider's node files replaces the limits with one line saying why (the
failed read with Try again), and the footer offers nothing to generate.
system service's, for a root shell. The command downloads from the
installation's public URL, never the browser's address, so it works as shown on
any host. Until the installation is read, a line says it is being checked; a
failed read, a public URL other machines can't use (loopback or not HTTPS), or a
console without the provider's node files replaces the limits with one line
saying why (the failed read with Try again), and the footer offers nothing to
generate. Once the node is ready, while Getting started is open, one line under
the green status names the next step (set a default model, or finish Getting
started) with a text action to System or the Overview.
- **Clean up the host**: after a node is removed, a dialog gives the host's
uninstall command in the same Terminal block, a Graphite line that it deletes no
sandboxes, volumes or images (and, for microsandbox, keeps its image store and
data), and the no-sudo form behind an "Installed without sudo?" disclosure. A
node enrolled with an earlier Core address adds an "Old Core address gone?"
disclosure with the `--force` form; a loopback console carries Add node's amber
note. Done dismisses it and focus returns to the page heading.
disclosure with the `--force` form. The command, too, downloads from the public
URL, which the dialog reads again if it is not at hand: until then one line says
it is being checked, a failed read says so with Try again, and a public URL other
machines can't use (loopback, or none) gets a line saying the service stays on the
host and no command can be given. Done dismisses it and focus returns to the page
heading.
- **Use Docker instead of microsandbox?**: choosing Docker in sandbox setup lists
what it gives up, each point a 600 Ink lead over a Graphite line: weaker
isolation (containers share the host kernel; microsandbox gives each sandbox
its own microVM), root-equivalent access (the node's account joins the docker
group) and limited use (trusted workloads, or hosts without KVM). The footer
holds Use Docker (outline) and Keep microsandbox (primary), which takes focus;
closing or Escape keeps microsandbox too.
- **Edit node**: the name, then the sandbox limit with one 12px Graphite line under
it once the node's heartbeat has the host's CPUs and memory: the host, each
sandbox's size and at most how many fit. The Nodes list and a node's Capacity
show "Active / limit" for Docker and microsandbox alike, so a saved limit shows
where it was set.
- **How to call**: wherever a new key is shown, a card under it gives three
copyable samples, each a Margin Gray block with a Hairline and its label and copy
button in a header row: a Shell block exporting `OPENAI_BASE_URL` (the
Expand Down Expand Up @@ -645,17 +659,20 @@ at zero).

### Notices
Errors are popups, never lines inserted into a page. A failed action whose outcome
needs a decision (an uncertain sandbox change) opens an error dialog with Core's
reason and the next step as its primary button. A failed refresh that keeps the last
data on screen, projects that could not be read, and other failed actions are
reported in an error toast with the reason. Only when a page or section has nothing to show
does an error state take the place of its content; errors inside a dialog or a form
needs a decision (a sandbox change with no answer, a timeout or a 5xx) opens an
error dialog with the reason and the next step as its primary button. A failed
refresh that keeps the last data on screen, projects that could not be read, and
other failed actions, Core's clear refusal of a sandbox change among them, are
reported in an error toast with the reason; a refusal leaves the page usable as it
was. Only when a page or section has nothing to show does an error state take the
place of its content; errors inside a dialog or a form
stay beside what they concern. Coverage notes (Margin Gray, Hairline, 12px corners,
12.5px Graphite) state bounded aggregation. A standing warning that needs action,
such as the Nodes page naming nodes still bound to an old Core address, is an
amber-tinted line at the top of the page body. Partial-data chips are amber-tinted pills
with a help tip. Safety notices (a key shown once, a destructive consequence) stay
visible in body text.
amber-tinted line at the top of the page body; each of those nodes' status reads
Old address (amber dot) with "Remove and add again" under it in 12px Graphite.
Partial-data chips are amber-tinted pills with a help tip. Safety notices (a key
shown once, a destructive consequence) stay visible in body text.

### Onboarding
Signing in and the console tour share one frame: a dark stage on the left (always
Expand Down
18 changes: 13 additions & 5 deletions apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,16 @@ workbench.
backend (microsandbox by default; Docker only after a confirmation of its weaker
isolation) or E2B account, the size of each sandbox (own machines only; E2B takes the
template build's), a review, and advanced settings
with the complete form — then the node list with each node's capacity, host
figures and allocations, enrollment, renaming, sandbox limits and removal; Add node
with the complete form — then the node list with each node's capacity (active
sandboxes against its limit, for every backend), host figures and allocations,
enrollment, renaming, sandbox limits (beside the host's CPUs and memory and at most
how many sandboxes of the deployment's size they hold) and removal; Add node
asks for the node's sandbox limits before it issues the one-time command, which installs
the node with sudo as a system service (a disclosure gives the command without sudo, as a
user service); it issues none before the installation is read, while the public URL is
user service); both commands download from the installation's public URL, never the
browser's address; it issues none before the installation is read, while the public URL is
loopback, or when the console lacks the provider's node files; after Remove, a dialog gives
the host's uninstall command), System (the
the host's uninstall command, or says none can be given without a usable public URL), System (the
installation's public address, API base URL, installation ID and source commit, read-only;
each harness's default model, set, replaced or cleared there beside its read-only startup
state; the sandbox configuration every project shares, with a link to Nodes where it
Expand All @@ -99,6 +102,9 @@ workbench.
- A node whose provider is not ready names the reason (Docker unreachable, no Docker
limits, missing Runtime image, no KVM, missing microsandbox components, a host too
small) and its fix in the help tip beside its status, wherever that status shows.
- A node enrolled with an earlier Core address gets no new sandboxes, so on the Nodes
list and its page its status is Old address, with "Remove and add again", never
Available.
- **E2B deployments** have no machines: the Nodes entry becomes Sandbox backend,
and Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud
(running, starting, size, template build) instead of node capacity, with no node column
Expand All @@ -117,7 +123,9 @@ workbench.
samples of the newest active project, preferring one with an active key.
Completion comes from reads the
console already makes. It can be hidden; Show Getting started in the sidebar
opens it again, and it ends with a brief "You're set". The optional
opens it again, and it ends with a brief "You're set". While it is open, Add node
ends with the next step once its node is ready: the default model while that is to
do, otherwise back to the checklist. The optional
three-chapter tour of the console (Monitor, Resources, Platform) opens from it,
on the sign-in stage.
- Terminology: API terms stay in English in the Chinese UI (Agent, Session, Turn,
Expand Down
67 changes: 58 additions & 9 deletions apps/web/e2e/nodes.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test";

import { expectManagementBoundary, openConsole, resetFixture, setNode, writes } from "./console";
import { expectManagementBoundary, failNext, openConsole, resetFixture, setNode, writes } from "./console";

test.afterEach(async ({ request }) => expectManagementBoundary(request));

Expand All @@ -20,13 +20,11 @@ test("adds a node: host requirements, a sudo command and one without, a countdow
await expect(add.getByText("SELinux is not enforcing (otherwise use the no-sudo command)")).toBeVisible();
await expect(add.getByText("One Core per host: a host already running a node for another Core is refused.")).toBeVisible();
await expect(add.getByText("CPUs and memory for at least one sandbox: 2 CPU · 4 GiB; about 2 GB of disk for the Runtime image")).toBeVisible();
await expect(add.getByText(/^Reaches http:\/\/127\.0\.0\.1:\d+ and https:\/\/core\.example\.com; sandboxes reach https:\/\/core\.example\.com$/)).toBeVisible();
await expect(add.getByText("Reaches https://core.example.com, as do its sandboxes")).toBeVisible();
await expect(add.getByText("parsar-node joins the docker group, which is equivalent to root on this host.")).toBeVisible();
await expect(add.getByText(/\/dev\/kvm/)).toHaveCount(0);
// Preparing a user instead of using sudo waits behind its disclosure.
await expect(add.getByText("sudo usermod -aG docker NODE_USER")).toBeHidden();
// The fixture console runs on loopback, where another machine can't download from it.
await expect(add.getByRole("note")).toContainText("other machines can't reach");
await add.getByLabel("Sandboxes at once").fill("3");
const tokenRequest = () => page.waitForRequest((sent) => sent.method() === "POST" && sent.url().endsWith("/core/v1/sandbox/enrollment-tokens"));
const issued = tokenRequest();
Expand All @@ -38,6 +36,9 @@ test("adds a node: host requirements, a sudo command and one without, a countdow
// The token goes on stdin to the checked installer, run with sudo unless the shell is root.
await expect(field).toHaveValue(/^ \(umask 077;.*\|\| s=sudo\n/);
await expect(field).toHaveValue(/\| \$s python3 "\$d\/node-install\.pyz" --enrollment-token-stdin /);
// It downloads from, and names as its source, the public URL, not the loopback address this browser uses.
await expect(field).toHaveValue(/curl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' /);
await expect(field).toHaveValue(/ --source-url 'https:\/\/core\.example\.com' --core-url 'https:\/\/core\.example\.com' /);
await expect(add.getByText("If the command is interrupted or the download stalls, run the same command again: the download resumes.")).toBeVisible();
await expect(add.getByRole("timer")).toHaveText(/^Expires in (10:00|9:\d\d)$/);
const progress = add.getByRole("status", { name: "Registration progress" });
Expand Down Expand Up @@ -132,9 +133,8 @@ test("issues no command before the installation is read, for a loopback public U
await expect(add.getByRole("button", { name: "Generate command" })).toHaveCount(0);
await page.unroute("**/core/v1/installation");
await add.getByRole("button", { name: "Try again" }).click();
// Nodes on other machines can't reach a loopback public_url; this replaces the note about the browser's address.
// Nodes on other machines can't reach a loopback public_url.
await expect(add.getByRole("status")).toHaveText("Nodes need an HTTPS public URL that other machines and their sandboxes can reach: set public_url in config.json and run parsar apply");
await expect(add.getByRole("note")).toHaveCount(0);
await expect(add.getByRole("button", { name: "Generate command" })).toHaveCount(0);
await add.getByRole("button", { name: "Close dialog" }).click();

Expand All @@ -160,7 +160,7 @@ test("removes a node after confirmation", async ({ page, request }) => {
await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("edge-03");
// The host still runs the node until it is uninstalled there: with sudo, or as the user that installed it.
const cleanup = page.getByRole("dialog", { name: "Clean up the host" });
await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/\| s=sudo\n[^]*\n\$s python3 "\$d\/node-install\.pyz" --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/);
await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/\| s=sudo\ncurl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' [^]*\n\$s python3 "\$d\/node-install\.pyz" --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/);
await cleanup.getByText("Installed without sudo?").click();
await expect(cleanup.getByLabel("Uninstall command without sudo", { exact: true })).toHaveValue(/\npython3 "\$d\/node-install\.pyz" --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/);
// Nothing to force for a node on the current address; closing leaves focus on the page, as the row is gone.
Expand All @@ -170,6 +170,28 @@ test("removes a node after confirmation", async ({ page, request }) => {
await expect(page.getByRole("heading", { name: "Nodes", level: 1 })).toBeFocused();
});

test("marks a node on an old Core address in its row, beside each node's limit", async ({ page, request }) => {
await openConsole(page, request, "nodes", { installation: "stale" });
const row = page.getByRole("row", { name: /core-01/ });
await expect(row).toContainText("Old address");
await expect(row).toContainText("Remove and add again");
await expect(row).not.toContainText("Available");
// Docker nodes show their limit too.
await expect(row).toContainText("5 / 8");
});

test("gives the host's uninstall command even when the installation must be read again", async ({ page, request }) => {
await openConsole(page, request, "nodes");
await page.route("**/core/v1/installation", (route) => route.fulfill({ status: 500, json: { error: { message: "Unavailable.", type: "server_error", code: null, param: null } } }));
await page.getByRole("button", { name: "Remove edge-03" }).click();
await page.getByRole("dialog", { name: "Remove node" }).getByRole("button", { name: "Confirm removal" }).click();
const cleanup = page.getByRole("dialog", { name: "Clean up the host" });
await expect(cleanup.getByRole("alert")).toContainText("The installation couldn't be read, so no command can be issued.");
await page.unroute("**/core/v1/installation");
await cleanup.getByRole("button", { name: "Try again" }).click();
await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/'https:\/\/core\.example\.com\/node-install\/node-install\.pyz'/);
});

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();
Expand Down Expand Up @@ -282,10 +304,11 @@ test("keeps the saved size and Runtime for the same backend, and starts another

test("reports a failed sandbox change in a dialog, then reads the state again", async ({ page, request }) => {
await openConsole(page, request, "nodes");
// Core's answer is lost, so the change may have been saved.
await page.route("**/core/v1/sandbox/deployment/maintenance", (route) => route.fulfill({
status: 409,
status: 503,
contentType: "application/json",
body: JSON.stringify({ error: { message: "The deployment changed.", type: "conflict_error", code: "sandbox_deployment_conflict", param: null } }),
body: JSON.stringify({ error: { message: "Unavailable.", type: "server_error", code: null, param: null } }),
}));
await page.getByRole("button", { name: "Enter maintenance to change provider" }).click();
const failed = page.getByRole("dialog", { name: "Couldn't confirm the sandbox change" });
Expand All @@ -294,13 +317,39 @@ test("reports a failed sandbox change in a dialog, then reads the state again",
await expect(page.getByRole("button", { name: "Enter maintenance to change provider" })).toBeEnabled();
});

test("keeps the page usable when Core refuses a sandbox change, and shows Core's reason", async ({ page, request }) => {
await openConsole(page, request, "nodes", { sandbox: "none" });
await failNext(request, { method: "POST", path: "/sandbox/deployment", status: 403, message: "This console is read-only." });
await page.getByRole("button", { name: "Own machines" }).click();
await page.getByRole("button", { name: "microsandbox Recommended" }).click();
await page.getByRole("button", { name: /^Standard/ }).click();
const save = page.getByRole("button", { name: "Save configuration" });
await save.click();
// A clear refusal changed nothing: no "couldn't confirm" dialog, and the same page to try again.
await expect(page.getByText("This console is read-only.")).toBeVisible();
await expect(page.getByRole("dialog")).toHaveCount(0);
// One code covers several reasons, so a conflict shows Core's own.
await failNext(request, { method: "POST", path: "/sandbox/deployment", status: 409, code: "sandbox_deployment_conflict", message: "Another administrator changed the deployment; it is now at generation 2." });
await save.click();
await expect(page.getByText("Another administrator changed the deployment; it is now at generation 2.")).toBeVisible();
await expect(page.getByRole("dialog")).toHaveCount(0);
await save.click();
await expect(page.getByText("c0ffee000000")).toBeVisible();
});

test("renames a node and sets how many sandboxes run on it at once", async ({ page, request }) => {
await openConsole(page, request, "nodes?id=node-local");
// A Docker node shows its limit too, and the edit shows what the host holds.
const capacity = page.getByRole("region", { name: "Capacity" });
await expect(capacity).toContainText("Active / limit");
await expect(capacity).toContainText("5 / 8");
await page.getByRole("button", { name: "Edit node" }).click();
const edit = page.getByRole("dialog", { name: "Edit node" });
await expect(edit.getByText("Host: 16 CPU · 64 GiB. Each sandbox: 2 CPU · 4 GiB. Suggested: at most 8 at once.")).toBeVisible();
await edit.getByLabel("Name").fill("core-01-large");
await edit.getByLabel("Sandboxes at once").fill("6");
await edit.getByRole("button", { name: "Save" }).click();
await expect(edit).toBeHidden();
await expect(page.getByRole("heading", { name: "core-01-large", level: 1 })).toBeVisible();
await expect(capacity).toContainText("5 / 6");
});
11 changes: 10 additions & 1 deletion apps/web/src/features/overview/getting-started.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { CoreHarness, SandboxDeployment } from "@agents-core-web/agents-cli
import { afterEach, describe, expect, it, vi } from "vitest";

import type { FleetState } from "../fleet/use-sandbox-fleet";
import { checklistStorageKey, checklistView, gettingStartedSteps, rememberInstallation } from "./getting-started";
import { checklistStorageKey, checklistView, gettingStartedSteps, nextStepAfterNode, rememberInstallation } from "./getting-started";
import { node, project } from "./test-fixtures";

const deployment = (overrides: Partial<SandboxDeployment> = {}): SandboxDeployment => ({
Expand Down Expand Up @@ -90,3 +90,12 @@ describe("Getting started visibility", () => {
expect(checklistStorageKey({ status: "loading" })).toBeNull();
});
});

describe("the next step after a node is ready", () => {
it("is the default model while it is to do, else the checklist, and only while the checklist is open", () => {
expect(nextStepAfterNode(true, "todo")).toBe("default-model");
expect(nextStepAfterNode(true, "done")).toBe("getting-started");
expect(nextStepAfterNode(true, null)).toBeNull();
expect(nextStepAfterNode(false, "todo")).toBeNull();
});
});
Loading