diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 9f24ec16a..0b6a5bc2c 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -544,17 +544,26 @@ 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 @@ -562,6 +571,11 @@ request runs. 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 @@ -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 diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 4111b9d23..2fcaa89a8 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -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 @@ -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 @@ -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, diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 8cf826005..a02689aa2 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -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)); @@ -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(); @@ -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" }); @@ -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(); @@ -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. @@ -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(); @@ -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" }); @@ -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"); }); diff --git a/apps/web/src/features/overview/getting-started.test.ts b/apps/web/src/features/overview/getting-started.test.ts index 8a8137456..946e176b5 100644 --- a/apps/web/src/features/overview/getting-started.test.ts +++ b/apps/web/src/features/overview/getting-started.test.ts @@ -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 => ({ @@ -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(); + }); +}); diff --git a/apps/web/src/features/overview/getting-started.ts b/apps/web/src/features/overview/getting-started.ts index abaae996d..fc52a1e0f 100644 --- a/apps/web/src/features/overview/getting-started.ts +++ b/apps/web/src/features/overview/getting-started.ts @@ -69,7 +69,7 @@ function sandboxStep(fleet: FleetState): GettingStartedSteps["sandboxes"] { * Done once the default harness has a deployment default model provider; * without a default harness, once any enabled harness has one. */ -function modelStep(harnesses: readonly CoreHarness[] | "failed" | undefined): StepState { +export function modelStep(harnesses: readonly CoreHarness[] | "failed" | undefined): StepState { if (!harnesses || harnesses === "failed") return harnesses ? "unknown" : null; const target = harnesses.find((harness) => harness.default); const set = target ? target.model_provider !== null : harnesses.some((harness) => harness.enabled && harness.model_provider !== null); @@ -151,6 +151,21 @@ export function writeChecklistMemory(key: string, value: Exclude