diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab0832e3c..126759605 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,6 +68,12 @@ described in `apps/web/DESIGN.md`. Keep explanations behind help tips, but keep errors, warnings and safety notices visible. Browser-derived metrics state their coverage, keep missing values missing, bound their fan-out and time, report a failed read as failed and never imply deployment-wide or billing totals. +Sandbox deployment setup, configuration, reset and progress belong to the System +secondary page (`#system?id=sandbox`). Nodes owns node management; Overview and +metrics pages link to these owners instead of repeating their controls or details. +Keep uncommon resource edits and rollout details in dialogs, and avoid repeating +the same information within or across pages. Core responses remain the source of +truth for deployment and connection state. For subsequent alignment and milestone closure batches, the main thread coordinates design, shared interface agreements, file ownership, integration and merge. First diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 73d80fc76..ef8ab9f2f 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -736,7 +736,7 @@ while the deployment cannot be read; Show Getting started, a quiet row above the sidebar's account controls, opens it again at any time. ### Sandbox setup -Setting up hosted sandboxes is a set of pages inside the Nodes page, one decision each: where sandboxes run (own +Setting up hosted sandboxes is a set of pages inside System’s Sandbox configuration secondary page, one decision each: where sandboxes run (own machines or E2B), then the backend or the E2B account, then the size of each sandbox (three presets; E2B skips it, since each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes @@ -750,25 +750,53 @@ read-only: it is config.json's `public_url`, and the console never asks for it. loopback address carries an amber line under it: only the Core machine reaches it. When Core rejects the configuration for it (E2B with a loopback `public_url`), a red-tinted block under the review keeps Core's message and adds the config file and -apply command as copyable values. A save attempt clears the E2B key, so the review -then says to enter it again, with a link to that step. +apply command as copyable values. A save attempt clears the transient E2B key. Initial setup then asks for it again, +with a link to that step; an update may leave it blank to keep the committed key. Advanced settings, one link away, hold the complete form: resources (not for E2B), 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). Same-backend editing starts at size or E2B credentials with -the provider fixed, and is available only with zero held resources; -changing the backend requires reset and then a new setup. E2B always needs its key -again. Optional explanations sit behind help tips; errors and safety consequences -remain visible. +the provider fixed. It is an online configuration update, including when older +sandboxes remain: existing node identities and resource ownership are retained. +Changing the backend or E2B team requires reset and then a new setup. E2B updates +can omit the key to retain it; every explicitly entered key takes the verified +replacement path and advances the target generation on success, including the same +value. Rejections remain inline with a safe +reason and a deliberate way back to reset; never infer teams from a key, auto-reset +or auto-resubmit. Optional explanations sit behind help tips; errors and safety +consequences remain visible. + +### Configuration generations +A single rollout row opens a details dialog for Core's target generation, +previous-generation sandboxes and rollout counts. Poll rapidly only while Core reports preparing, or +while the independent reset is active. Settled is preparation state, not proof that +all nodes are ready or all older Sessions have ended. Retained old resources alone +must not keep rapid polling alive. Render failed, update-required and unknown target +states distinctly. Keep offline/live-provider status separate from a node's durable +serving-generation pin; the pin alone never means the node is online or ready. +Node detail shows the serving generation and target preparation; allocation detail +shows the owned configuration generation. Do not calculate rollout completion from +these rows or promise immediate placement on the target. + +A generation-only update within the same installation/backend lifecycle retains +compatible previous node/allocation evidence while refreshing. Failed or pending +reads visibly qualify those observations; never replace them with fabricated zeros. +Reset, backend and installation lifecycle changes still discard incompatible data. +The shared deployment query and write ownership below continue to govern navigation, +late reads, explicit retries and login isolation. This final online experience ships +only with the qualified node-generation protocol; fixture results alone do not +establish native online-upgrade capability. ### Sandbox reset The deployment section offers explicit reset rather than maintenance/resume. Reuse -its existing panels and confirmation dialogs. Auto clear is selected first, with a +its existing panels and confirmation dialogs. Keep the confirmation to one concise +consequence paragraph, two mode choices, the auto deadline and footer actions. +Put cleanup sequencing and preservation details in help tips. Auto clear is selected first, with a one-hour deadline editable from 5 minutes to 24 hours; Force clear and escalation require destructive confirmation. State directly that hosted work is archived, remaining active work may be cancelled, archived Sessions cannot resume and -unpersisted workspace contents may be lost. Histories and persisted Files/Artifacts -remain, and self-hosted execution is unaffected. Cancelling an active reset stops +unpersisted workspace contents may be lost. Details about preserved histories, Files/Artifacts and unaffected self-hosted +execution live behind the reset impact help tip. Cancelling an active reset stops further clearing but cannot undo completed archives. A persistent progress panel uses Core's busy, idle and cleanup counts, deadline and @@ -777,19 +805,24 @@ never offer a browser-side force-release shortcut. Poll the deployment every fiv seconds only while its reset is non-null. A passed deadline does not establish force or completion; only a Core response does. Completion opens the existing setup flow, with a new explicit save using the generation read from Core, including zero on a -fresh install. No automatic configuration replay or online-rollout progress exists. +fresh install. Reset and online configuration rollout have independent authoritative +progress; neither automatically replays configuration. Read deployment progress independently of node details. Partial failures retain successful facts with a visible stale/unavailable notice. An uncertain write opens the existing recovery dialog and requires a new authoritative read before another mutation; refresh reads state and never resubmits the write. The connection's QueryClient owns both the authoritative deployment and pending or uncertain writes -across route transitions. Leaving Nodes cannot cancel or forget a submitted reset, +across route transitions. Leaving Sandbox configuration cannot cancel or forget a submitted reset, and a cached node snapshot cannot replace a newer reset or completion learned on -Overview or System. Returning to Nodes reads the shared deployment immediately and +Overview. Returning to Nodes or Sandbox configuration reads the shared deployment immediately and refreshes node evidence separately. Only a successful authoritative read begun after the write settles can release the mutation block; an earlier or still-pending read -cannot. Logout clears this connection-scoped state. +cannot. Submitting consumes the reset confirmation even if its outcome is uncertain; +recovery uses the separate read-and-review dialog. Observation retries preserve +applicable non-secret configuration drafts. A changed installation, owner epoch, +backend, mode or generation discards the prior draft and confirmation. Logout clears +this connection-scoped state. ### System page Four sections, each saying where it changes. Installation: the public address, API @@ -801,13 +834,35 @@ a help tip), then the provider's protocol, base URL, whether a key is configured token limits when set and the update time, or Not set. Set and Replace open one form dialog; the key field is a required password input, never prefilled or shown and forgotten when the form closes. Core's rejection stays in red inside the form; Clear -is a ConfirmDialog. Sandboxes: the shared sandbox configuration, with a "Change on -the Nodes page" text action in the section header. Startup settings: a line naming +is a ConfirmDialog. Usage details opens Core’s observations in a separate dialog. +Sandboxes: one navigation row to the Sandbox configuration secondary page; do not +repeat its configuration facts on System. Startup settings: a line naming the config file and the apply command as copyable chips, with when they were last applied, over a table of each setting, its value and the services a change restarts. Sensitive settings show only Configured or Not set; Default and Fixed after install are neutral pills beside the value. +### One place for each task +A configuration or operation has one home. Other pages link to it instead of +repeating the same panel. System links to the Sandbox configuration secondary +page; Nodes contains node management. Keep the configuration page flat: the +resource editor is a dialog, and rollout is one status row with a details +action. Put low-frequency counts and generation metadata in that dialog. +Explanatory prose belongs in help tips, not rows of small print. Keep actionable +errors and unresolved state visible without duplicating the whole workflow. + +### Diagnostic observations +Failure reasons belong beside the failed Session or Turn status. Their first +read uses a skeleton; an unavailable reason names that state and puts the read +retry beside its help tip. Technical classifications are +translated through one catalogue, not shown as raw error codes. Trace Timing +labels Core receipt times separately from tool-reported duration; explanations +of batched delivery, clock differences and historical gaps live in help tips. + +The self-hosted connection panel names Core's observed state, the bound key and +last heartbeat. The bound-key action follows the existing rotation confirmation +and one-time credential flow. Stale observations cannot complete Run on host. + ### 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 diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 881c7d8f0..af4851e24 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -82,32 +82,21 @@ workbench. Creator column naming the creating key. Detail pages show the resource's facts and offer Delete. - **Platform**: Projects and keys (projects, their assets and usage, named keys, - write history), Nodes (sandbox setup as pages — where sandboxes run, the - 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 (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); 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, 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 provider, set, replaced or cleared there beside its read-only startup - state; the sandbox configuration every project shares, with a link to Nodes where it - changes; and Core's startup settings from config.json, with the file and the apply command - that change them). + write history), Nodes (the node list, capacity, host figures, allocations and + individual node operations). Add node asks for limits before issuing its + one-time command; installers use Core's public URL and require supported node + artifacts. Removal offers the host's uninstall command. System owns installation + facts, each harness's default model provider, startup settings, and a link to + the Sandbox configuration secondary page. That page owns setup, resource edits, + rollout details and reset. Setup selects a backend, size and Runtime, then asks + for a deliberate save; own-machine setup continues to Add node. - 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. -- **Sandbox reset** is an explicit administrator operation on Nodes. Auto clear is +- **Sandbox reset** is an explicit administrator operation in System → Sandbox configuration. Auto clear is the default, with a one-hour deadline (5 minutes–24 hours); Force clear requires destructive confirmation. Reset stops new hosted Session admission, clears idle, suspended and pending hosted work, and waits for busy Turns and file writes until @@ -117,10 +106,24 @@ workbench. without undoing archives. Core alone reports progress and completion, including resources blocked on named offline nodes; force does not bypass their cleanup. Completion clears the backend configuration and retires old nodes/enrollment - credentials. A new configuration is then a separate deliberate save. Same-backend - editing currently requires zero held resources; online rollout is not 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 + credentials. A new configuration is then a separate deliberate save. +- **Online sandbox configuration** changes the same backend's resources, Runtime + or E2B template without retiring existing nodes or changing existing Sessions' + resource ownership. New placement follows Core's qualified capacity; saving a + target does not promise immediate placement on it. Configuration rollout shows + Core's target preparation and retained previous-generation sandbox count. A + settled rollout can still have failed, update-required or unknown nodes and old + resources. An offline node stays offline even when it has a recorded serving + generation. Node and allocation detail distinguish the serving pin, target + preparation and each resource's configuration generation. +- **E2B credential replacement** uses the same configuration form. Setup requires + a key; leaving it blank during an update keeps the saved key. An explicit key, + even the same value, is verified as a replacement and advances the target generation + after successful verification. + Another backend or E2B team requires a deliberate reset. A rejected or uncertain + replacement never clears the committed configuration or replays the write. +- **E2B deployments** have no machines: Nodes offers a link to System's sandbox configuration. + 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 or Add node action; a sandbox's dialog adds its disk use. - **microsandbox** suspends idle sandboxes into snapshots, so its nodes show how @@ -164,6 +167,12 @@ workbench. page, the console tells developers to set `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (a key of the project), with curl and Python samples that list Agents and create a Session. +- **One home for each setting.** System owns sandbox configuration through its + Sandbox configuration secondary page. This is the only place to set up, + update, reset or inspect deployment rollout. Nodes owns the node list and + individual node operations. Overview and metrics link to these owners instead + of repeating their configuration or rollout panels. Resource editing uses a + dialog; rollout counts and generations appear in its details dialog. - **Web API only.** Every read and write goes through `/core/v1/**`. The console holds no API key and sends nothing to `/v1`. - **No asset writes except delete.** Assets are created and changed only by @@ -184,6 +193,13 @@ workbench. submit that result; environment connection waits stay distinct from function waits. - **Session history is read-only.** A Session page reads the Session, its Items and Turns and polls while work is in flight; there is no live event stream. +- **Failure diagnostics.** Failed Session and Turn rows read Core diagnostics and + translate its classified reason. The console never infers a cause from raw + logs. Unavailable or mismatched diagnostics offer an explicit read retry; + refreshing does not replay execution. Trace Timing keeps each Item's Core + receipt interval separate from public Turn times and native tool duration. + Historical missing timestamps stay unknown, negative clock intervals stay + missing, and bounded response truncation remains visible. - **Executor credentials.** Only Core issues the credential file a self-hosted executor needs, with the deployment's Core key. A Session page whose environment is self-hosted has an Executor credentials section: issue a credential (shown @@ -191,6 +207,18 @@ workbench. old one stops working immediately) or revoke it (the executor disconnects and won't retry; its container keeps running until stopped). The file lets one executor connect for that environment only; it cannot call the Agents API. +- **Default provider observations.** Each configured harness offers Usage details + for Core's last successful use and any newer classified provider error. Missing + records remain unknown; an error at or before the last success is no longer + actionable. These are best-effort observations, not readiness checks. Failed + refreshes qualify retained records, and replacing the provider starts a new + observation history. +- **Host connection.** Core's connection observation and credential metadata + share one five-second read while visible. Never connected, connected, + disconnected, bound credential revoked, and unknown are distinct; a recent + heartbeat alone never proves connectivity. Only a fresh connected read marks + Run on host done. Stale or failed reads withhold completion. Recovery rotates + the bound key and reruns the same command rather than suggesting a new key. - **Connect a host.** When the console serves the self-hosted installer, the section also gives the command that installs the executor on the administrator's host from Core's `public_url` (checksum-verified, no secret in @@ -202,6 +230,10 @@ workbench. connected. Without a `public_url`, with a loopback one, or when the Session's `remote_url` is not `wss://`, the section says why instead of showing a command. +- **Typed write errors.** Known Core codes use shared bilingual copy and safe + typed details. Exact Core field paths attach definite refusals to the relevant + input. Unknown codes retain Core's fallback message; uncertain write outcomes + stay form-level and are never retried automatically. - **Read failures.** Overview and Session log distinguish unavailable reads from successful empty results. Failed reads have a visible retry; retained or partial data says it may be incomplete or out of date, and Session filter totals stay diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index 63e4e1590..ead3fbd2e 100644 --- a/apps/web/e2e/access.spec.ts +++ b/apps/web/e2e/access.spec.ts @@ -128,7 +128,8 @@ test("shows every page empty on a fresh install, and Getting started hides, come await openConsole(page, request, "overview", { fresh: true, sandbox: "none", nodes: "none" }); // What each page shows once its reads are done: an empty state, Core's own figures, or sandbox setup. const loaded = (view: string) => view === "core-metrics" ? page.locator(".kpi-strip").first() - : view === "nodes" ? page.getByRole("heading", { name: "Where should sandboxes run?" }) + : view === "nodes" ? page.getByText("Set up sandbox hosting in System before adding nodes.") + : view === "system" ? page.getByRole("button", { name: "Manage sandbox configuration" }) : page.locator(".console-empty").first(); for (const view of ["core-metrics", "agent-metrics", "sandbox-metrics", "sessions", "agents", "templates", "skills", "files", "vaults", "projects", "nodes", "system", "overview"]) { await page.goto(`/#${view}`); @@ -150,6 +151,7 @@ test("shows every page empty on a fresh install, and Getting started hides, come const step = checklist.getByRole("listitem").filter({ hasText: "Get sandboxes ready" }); await expect(step).toContainText("To do"); await step.getByRole("button", { name: "Set up sandboxes" }).click(); + await expect(page).toHaveURL(/#system\?id=sandbox$/); await expect(page.getByRole("heading", { name: "Where should sandboxes run?" })).toBeVisible(); }); diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 82aba5d5b..44b6af91c 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -1,4 +1,4 @@ -import type { SandboxDeployment } from "@agents-core-web/agents-client"; +import type { SandboxDeployment, SandboxNodeRollout } from "@agents-core-web/agents-client"; import { expect, type APIRequestContext, type Page } from "@playwright/test"; const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; @@ -39,7 +39,7 @@ export async function failNext(request: APIRequestContext, failure: { method: st } /** Registers a node, or changes one, as a host running the last enrollment command (or, with its `enrollment_id`, another one) would. */ -export async function setNode(request: APIRequestContext, node: { id: string; name?: string; online?: boolean; provider_ready?: boolean; diagnostic?: string; enrollment_id?: string }) { +export async function setNode(request: APIRequestContext, node: { id: string; name?: string; online?: boolean; provider_ready?: boolean; diagnostic?: string; enrollment_id?: string; rollout?: SandboxNodeRollout }) { await request.post(`${fixture}/__fixture/node`, { data: node }); } diff --git a/apps/web/e2e/diagnostics.spec.ts b/apps/web/e2e/diagnostics.spec.ts new file mode 100644 index 000000000..63948f790 --- /dev/null +++ b/apps/web/e2e/diagnostics.spec.ts @@ -0,0 +1,92 @@ +import { expect, test } from "@playwright/test"; + +import { expectManagementBoundary, openConsole, writes } from "./console"; + +test.afterEach(async ({ request }) => expectManagementBoundary(request)); + +test("reads classified failures and separate Item receipts without execution writes", async ({ page, request }) => { + await page.route("**/core/v1/projects/*/sessions/*/diagnostics", async (route) => { + const response = await route.fetch(); + const data = await response.json(); + if (data.failure) data.failure = { ...data.failure, code: "authentication_error", params: {} }; + await route.fulfill({ response, json: data }); + }); + await page.route("**/core/v1/projects/*/sessions/*/turns/*/diagnostics", async (route) => { + const response = await route.fetch(); + const data = await response.json(); + if (data.failure) data.failure = { ...data.failure, code: "authentication_error", params: {} }; + data.items = data.items.map((item: object) => ({ ...item, started_at: "2026-09-28T09:00:00Z", completed_at: "2026-09-28T09:00:01.250Z", observed_duration_ms: 1250 })); + await route.fulfill({ response, json: data }); + }); + await openConsole(page, request, "sessions"); + await page.getByRole("radio", { name: /^Failed/ }).click(); + const row = page.getByRole("row").filter({ hasText: "The model provider rejected authentication" }).first(); + await row.getByRole("button", { name: /^Open Session / }).click(); + await expect(page.getByLabel("Session facts")).toContainText("Check its API key"); + await page.getByRole("radio", { name: "Trace", exact: true }).click(); + await page.locator(".trace-ledger-row-tools").first().click(); + await page.getByRole("tab", { name: "Timing", exact: true }).click(); + await expect(page.getByLabel("Core receipt times")).toContainText("1.3 s"); + await expect(page.locator(".trace-detail-timing")).toContainText("Tool-reported duration"); + await page.screenshot({ path: test.info().outputPath("diagnostics-desktop.png") }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: test.info().outputPath("diagnostics-mobile.png") }); + expect(await writes(request)).toEqual([]); +}); + +test("shows diagnostics failure honestly and can retry in Chinese", async ({ page, request }) => { + let failed = true; + await page.route("**/core/v1/projects/*/sessions/*/diagnostics", async (route) => { + if (failed) return route.fulfill({ status: 503, json: { error: { code: "internal_error", message: "RAW_SENTINEL", type: "server_error", param: null } } }); + const response = await route.fetch(); + const data = await response.json(); + if (data.failure) data.failure = { ...data.failure, code: "authentication_error", params: {} }; + await route.fulfill({ response, json: data }); + }); + await openConsole(page, request, "sessions"); + await page.getByRole("radio", { name: /^Failed/ }).click(); + const row = page.getByRole("row").filter({ hasText: "Failure details unavailable" }).first(); + await row.getByRole("button", { name: /^Open Session / }).click(); + await page.getByRole("button", { name: "Language and appearance" }).click(); + await page.getByRole("menuitemradio", { name: "简体中文" }).click(); + await expect(page.getByLabel("Session 信息")).toContainText("失败详情不可用"); + await expect(page.locator("body")).not.toContainText("RAW_SENTINEL"); + failed = false; + await page.getByLabel("Session 信息").getByRole("button", { name: "失败详情", exact: true }).click(); + await page.getByLabel("Session 信息").getByRole("button", { name: "重试诊断读取", exact: true }).click(); + await expect(page.getByLabel("Session 信息")).toContainText("模型服务认证失败"); + expect(await writes(request)).toEqual([]); +}); + +test("connection reads govern host completion and bound-key guidance", async ({ page, request }) => { + let connected = false; + let revoked = false; + let unavailable = false; + const keyId = "a0000000-0000-4000-8000-000000000001"; + await page.route("**/core/v1/projects/*/environments/*/executor-credentials", async (route) => { + if (route.request().method() !== "GET") return route.continue(); + if (unavailable) return route.fulfill({ status: 503, json: { error: { code: "internal_error", message: "Unreadable connection", type: "server_error", param: null } } }); + await route.fulfill({ json: { + data: [{ key_id: keyId, created_at: "2026-09-28T08:00:00Z", revoked_at: revoked ? "2026-09-28T09:01:00Z" : null }], + connection: { status: connected ? "connected" : "disconnected", bound_key_id: keyId, enrolled_at: "2026-09-28T08:00:01Z", last_seen_at: "2026-09-28T08:59:00Z" }, + } }); + }); + await openConsole(page, request, "sessions"); + await page.getByRole("row").filter({ hasText: "Self-hosted" }).first().getByRole("button", { name: /^Open Session / }).click(); + await expect(page.getByRole("region", { name: "Host connection", exact: true })).toContainText("Disconnected"); + await expect(page.getByText("Run on host · Done", { exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Rotate bound credential", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: /^Rotate credential / })).toHaveCount(0); + revoked = true; + await expect(page.getByRole("region", { name: "Host connection", exact: true })).toContainText("Bound credential revoked", { timeout: 10_000 }); + await expect(page.getByRole("button", { name: "Rotate bound credential", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Rotate to restore", exact: true })).toBeVisible(); + revoked = false; + connected = true; + await expect(page.getByRole("region", { name: "Host connection", exact: true })).toContainText("Connected", { timeout: 10_000 }); + await expect(page.getByText("Run on host · Done", { exact: true })).toBeVisible(); + unavailable = true; + await expect(page.getByRole("region", { name: "Host connection", exact: true })).toContainText("Unknown", { timeout: 10_000 }); + await expect(page.getByText("Run on host · Done", { exact: true })).toHaveCount(0); + expect(await writes(request)).toEqual([]); +}); diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 0c5b51198..b79218fd6 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -71,26 +71,26 @@ function installation() { }; } -/** Settled generation fields required by the strict client; this fixture does not simulate online preparation. */ -function settledRollout(mode) { - const nodes = mode === "nodes" ? { ready: 0, preparing: 0, failed: 0, update_required: 0, unknown: 0 } : null; - if (nodes) for (const node of state.nodes) nodes[node.rollout.state] += 1; - return { state: "settled", previous_generation_sandboxes: 0, nodes }; +const noNodeRollout = () => ({ state: "settled", previous_generation_sandboxes: 0, nodes: null }); +function nodeRollout(previous = 0) { + const nodes = { ready: 0, preparing: 0, failed: 0, update_required: 0, unknown: 0 }; + for (const node of state.nodes) nodes[node.rollout.state]++; + return { state: nodes.preparing > 0 ? "preparing" : "settled", previous_generation_sandboxes: previous, nodes }; } function unconfiguredDeployment(generation = 0, ownerEpoch = 3) { - return { installation_id: INSTALLATION_ID, provider: "", core_url: publicUrl(), rollout: settledRollout(""), reset: null, owner_epoch: ownerEpoch, generation, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }; + return { installation_id: INSTALLATION_ID, provider: "", core_url: publicUrl(), reset: null, rollout: noNodeRollout(), owner_epoch: ownerEpoch, generation, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }; } function configuredDeployment() { - return { installation_id: INSTALLATION_ID, provider: "docker", core_url: publicUrl(), rollout: settledRollout("nodes"), reset: null, owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: release }, specification_digest: "fixture", suspension: null }; + return { installation_id: INSTALLATION_ID, provider: "docker", core_url: publicUrl(), reset: null, rollout: nodeRollout(), owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: release }, specification_digest: "fixture", suspension: null }; } /** The E2B template build as Core read it when the selection was saved. */ const templateBuild = { status: "ready", resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 10240 } }; // E2B runs sandboxes in its cloud: no nodes, only what Core holds there. function e2bDeployment() { - return { ...configuredDeployment(), provider: "e2b", mode: "direct", rollout: settledRollout("direct"), resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, e2b: { template: "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", credential_configured: true, template_build: templateBuild } }; + return { ...configuredDeployment(), provider: "e2b", mode: "direct", rollout: noNodeRollout(), resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, e2b: { template: "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", credential_configured: true, template_build: templateBuild } }; } function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public", credentials = "configured", installers = true, artifacts = "docker,microsandbox") { @@ -118,7 +118,7 @@ function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "d // The demo deployment's default harness has a default model; a fresh install has none. harnesses: { claude_sdk: { enabled: true, default: false, provider: null }, - codex: { enabled: true, default: true, provider: fresh ? null : { object: "core.model_provider", harness: "codex", protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true, updated_at: new Date((now - 86400) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z") } }, + codex: { enabled: true, default: true, provider: fresh ? null : { object: "core.model_provider", harness: "codex", protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true, last_used_at: null, last_error_code: null, last_error_at: null, updated_at: new Date((now - 86400) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z") } }, mcode: { enabled: false, default: false, provider: null }, }, // "none": the deployment is not configured yet, so the Nodes page offers setup. @@ -285,6 +285,20 @@ function adminRead(response, path, url) { const session = own.sessions.find((entry) => entry.id === m[1]); if (!session) return error(response, 404, "No such Session."); if (!m[2]) return send(response, 200, session); + if (m[2] === "diagnostics") { + const failedTurn = (state.turns.get(session.id) ?? []).findLast((turn) => turn.status === "failed"); + return send(response, 200, { object: "core.session_diagnostics", session_id: session.id, status: session.status, + failure: session.status === "failed" ? { source: failedTurn ? "turn" : "environment_input", ...(failedTurn ? { turn_id: failedTurn.id } : {}), code: "internal_error", params: {}, failed_at: null } : null }); + } + const diagnostics = m[2].match(/^turns\/([^/]+)\/diagnostics$/); + if (diagnostics) { + const turn = (state.turns.get(session.id) ?? []).find((entry) => entry.id === diagnostics[1]); + if (!turn) return error(response, 404, "No such Turn."); + return send(response, 200, { object: "core.turn_diagnostics", session_id: session.id, turn_id: turn.id, status: turn.status, + failure: turn.status === "failed" ? { code: "internal_error", params: {}, failed_at: null } : null, + items: (state.items.get(session.id) ?? []).filter((item) => item.turn_id === turn.id).slice(0, 1000).map((item) => ({ item_id: item.id, started_at: new Date((turn.started_at ?? turn.created_at) * 1000).toISOString(), completed_at: null, observed_duration_ms: null })), + items_truncated: false }); + } if (m[2] === "turns") return send(response, 200, list([...(state.turns.get(session.id) ?? [])].reverse(), url)); if (m[2] === "items") return send(response, 200, list([...(state.items.get(session.id) ?? [])].reverse(), url)); if (m[2] === "runtime-observation") { @@ -356,19 +370,37 @@ async function sandboxRoute(request, response, path, url) { if (input.expected_generation !== state.deployment.generation) return error(response, 409, "The sandbox configuration changed. Refresh before submitting again.", "sandbox_generation_stale"); if (state.deployment.reset) return error(response, 409, "A sandbox reset is in progress.", "sandbox_reset_in_progress"); if (!initialize && input.provider !== state.deployment.provider) return error(response, 409, "Reset before changing the sandbox backend.", "sandbox_reset_required"); - if (!initialize && state.deployment.resources.allocations + state.deployment.resources.pending > 0) return error(response, 409, "Sandbox resources remain.", "sandbox_in_use"); if (initialize && state.deployment.provider) return error(response, 409, "The sandbox deployment is already configured.", "sandbox_deployment_conflict"); if (!initialize && !state.deployment.provider) return error(response, 409, "The sandbox deployment is not configured.", "sandbox_deployment_conflict"); const e2b = input.provider === "e2b"; if (!e2b && (!input.resources || !input.runtime)) return error(response, 400, "resources and runtime are required.", "invalid_sandbox_configuration"); // As Core (ErrSandboxPublicURLUnreachable): E2B sandboxes reach Core over the internet, which a loopback public_url cannot serve. if (e2b && state.installation === "local") return error(response, 409, "E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback (public_url in config.json, OAC_PUBLIC_URL for Core).", "sandbox_configuration_error"); + // Synthetic classifier outcomes only; never persist or echo submitted keys. + if (e2b) { + if (!input.e2b?.template || (initialize && !input.e2b.api_key) || (Object.hasOwn(input.e2b ?? {}, "api_key") && !input.e2b.api_key)) return error(response, 400, "The E2B API key was rejected.", "e2b_api_key_invalid"); + if (input.e2b.api_key === "fixture-other-team-key") return error(response, 409, "This E2B key cannot manage the retained deployment. Reset before changing teams.", "e2b_team_mismatch"); + if (input.e2b.api_key === "fixture-invalid-key") return error(response, 400, "The E2B API key was rejected.", "e2b_api_key_invalid"); + } // As Core: E2B may omit resources and adopt its template build's CPU and memory; only microsandbox suspends. const resources = input.resources ?? { cpus: templateBuild.resources.cpus, memory_mib: templateBuild.resources.memory_mib }; + const previous = state.deployment; + const specification = { resources, ...(input.runtime ? { runtime: input.runtime } : {}) }; + const explicitKey = e2b && Object.hasOwn(input.e2b, "api_key"); + const sameSelection = !initialize && JSON.stringify(specification) === JSON.stringify(previous.specification) && (!e2b || input.e2b.template === previous.e2b?.template); + // Omission can be a no-op; every explicit key, including identical bytes, + // takes the verified replacement path and advances the target generation. + if (sameSelection && !explicitKey) return send(response, 200, previous); + if (!initialize && !e2b) { + for (const node of state.nodes) node.rollout = { state: node.online ? "preparing" : "unknown", ready_generation: node.rollout.ready_generation }; + } + const held = initialize ? { allocations: 0, pending: 0 } : previous.resources; state.deployment = { - ...configuredDeployment(), provider: input.provider, mode: e2b ? "direct" : "nodes", rollout: settledRollout(e2b ? "direct" : "nodes"), - generation: state.deployment.generation + 1, owner_epoch: state.deployment.owner_epoch, - specification: { resources, ...(input.runtime ? { runtime: input.runtime } : {}) }, + ...configuredDeployment(), provider: input.provider, mode: e2b ? "direct" : "nodes", + generation: previous.generation + 1, owner_epoch: previous.owner_epoch, + resources: held, + rollout: e2b ? { ...noNodeRollout(), previous_generation_sandboxes: held.allocations + held.pending } : nodeRollout(held.allocations + held.pending), + specification, ...(e2b ? { e2b: { template: input.e2b?.template ?? "", credential_configured: true, template_build: templateBuild } } : {}), suspension: input.provider === "microsandbox" ? { idle_seconds: 300, retention_seconds: 86400 } : null, }; @@ -408,7 +440,7 @@ async function sandboxRoute(request, response, path, url) { const index = state.nodes.findIndex((node) => node.id === m[1]); if (index < 0) return error(response, 404, "No such node."); state.nodes.splice(index, 1); - state.deployment.rollout = settledRollout(state.deployment.mode); + if (state.deployment.mode === "nodes") state.deployment.rollout = nodeRollout(state.deployment.rollout.previous_generation_sandboxes); return send(response, 200, { id: m[1], deleted: true }); } return error(response, 404, "Not found."); @@ -534,6 +566,7 @@ async function harnessRoute(request, response, path) { object: "core.model_provider", harness, protocol: input.protocol, base_url: input.base_url, api_key_configured: true, ...(input.context_window ? { context_window: input.context_window } : {}), ...(input.max_output_tokens ? { max_output_tokens: input.max_output_tokens } : {}), + last_used_at: null, last_error_code: null, last_error_at: null, updated_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), }; return send(response, 200, entry.provider); @@ -568,11 +601,14 @@ async function fixtureRoute(request, response, url) { let node = state.nodes.find((entry) => entry.id === nodeId); if (!node) state.nodes.push(node = registeredNode(nodeId)); Object.assign(node, fields); - if (!fields.rollout) node.rollout = !node.online ? { state: "unknown", ready_generation: node.rollout.ready_generation } - : node.provider_ready ? { state: "ready", ready_generation: state.deployment.generation } - : node.diagnostic ? { state: "failed", ready_generation: node.rollout.ready_generation, diagnostic: node.diagnostic } - : { state: "unknown", ready_generation: node.rollout.ready_generation }; - state.deployment.rollout = settledRollout(state.deployment.mode); + // A control may supply an explicit target observation. Existing enrollment + // cases only supply connection facts, so give that newly joined target a pin. + if (!fields.rollout && ("online" in fields || "provider_ready" in fields || "diagnostic" in fields)) { + node.rollout = !node.online ? { state: "unknown", ready_generation: node.rollout.ready_generation } + : node.provider_ready ? { state: "ready", ready_generation: state.deployment.generation } + : { state: "failed", ready_generation: node.rollout.ready_generation, diagnostic: node.diagnostic || "provider_unavailable" }; + } + if (state.deployment.mode === "nodes") state.deployment.rollout = nodeRollout(state.deployment.rollout.previous_generation_sandboxes); if (!node.diagnostic) delete node.diagnostic; return send(response, 200, node); } diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index 26ecb45e2..4e73326f3 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -25,7 +25,7 @@ test("shows the deployment's health on Overview and each monitor page", async ({ await page.getByRole("button", { name: "Sandbox metrics" }).click(); await expect(page.getByRole("table").first()).toContainText("core-01"); // A degraded node names why its provider is not ready. - await expect(page.getByRole("button", { name: "Docker limits unsupported" })).toBeVisible(); + await expect(page.locator(".status-with-help").filter({ hasText: /^Provider not ready/ }).getByRole("button", { name: "Docker limits unsupported", exact: true })).toBeVisible(); }); test("opens a Session's conversation from the Session log, read-only", async ({ page, request }) => { @@ -37,7 +37,7 @@ test("opens a Session's conversation from the Session log, read-only", async ({ }); test("keeps a failed Session's reason in sight in the Session log and on its page", async ({ page, request }) => { - const reasons = /Sandbox allocation failed: node unavailable\.|Model provider returned 429 Too Many Requests\.|Tool call timed out after 300 s\./; + const reasons = /Execution could not complete\. No specific cause was reported\./; await page.setViewportSize({ width: 1280, height: 800 }); await openConsole(page, request, "sessions"); await page.getByRole("radio", { name: /^Failed/ }).click(); @@ -182,7 +182,7 @@ test("issues a new executor credential after the unanswered one was rotated from await page.unroute("**/executor-credentials"); // The page's refresh lists it; the administrator rotates it from its row. - await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await page.getByRole("button", { name: "Refresh", exact: true }).first().click(); const credentials = section.getByRole("table", { name: "Executor credentials" }); await credentials.getByRole("button", { name: /^Rotate credential / }).click(); // Rotating an active credential disconnects its host until the command is rerun with the new one. @@ -222,6 +222,7 @@ test("shows E2B's cloud instead of machines", async ({ page, request }) => { await expect(page.getByRole("button", { name: "Add node" })).toHaveCount(0); await expect(page.getByRole("columnheader", { name: "Node", exact: true })).toHaveCount(0); - await page.getByRole("navigation").getByRole("button", { name: "Sandbox backend" }).click(); - await expect(page.getByRole("heading", { name: "Sandbox backend", level: 1 })).toBeVisible(); + await page.getByRole("navigation").getByRole("button", { name: "Nodes", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Nodes", level: 1 })).toBeVisible(); + await expect(page.getByText("E2B runs sandboxes in its cloud. There are no nodes to manage.")).toBeVisible(); }); diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index c41f9ba14..0a0f673ba 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -197,7 +197,7 @@ test("gives the host's uninstall command even when the installation must be read }); 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 openConsole(page, request, "system?id=sandbox", { 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: "microsandbox Recommended" }).click(); @@ -205,8 +205,6 @@ test("sets up own-machine sandboxes page by page, with the Runtime from the dist await expect(page.getByRole("heading", { name: "Review and save" })).toBeVisible(); 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(); // Own machines continue straight to adding the first node, at its limits: no command is issued yet. await expect(page.getByRole("dialog", { name: "Add node" }).getByLabel("Sandboxes at once")).toBeVisible(); const add = page.getByRole("dialog", { name: "Add node" }); @@ -216,10 +214,16 @@ test("sets up own-machine sandboxes page by page, with the Runtime from the dist const tokenRequested = await page.waitForRequest((sent) => sent.url().endsWith("/core/v1/sandbox/enrollment-tokens"), { timeout: 1000 }).then(() => true, () => false); expect(tokenRequested).toBe(false); expect(await writes(request)).toEqual(["POST /core/v1/sandbox/deployment"]); + await add.getByRole("button", { name: "Close dialog" }).click(); + await page.getByRole("button", { name: "System", exact: true }).click(); + await page.getByRole("button", { name: "Manage sandbox configuration", exact: true }).click(); + // The saved specification carries the Runtime read from the console's manifest. + await page.getByRole("button", { name: "Configuration details", exact: true }).click(); + await expect(page.locator(".help-tip-popover").getByText("c0ffee000000", { exact: true })).toBeVisible(); }); test("preselects microsandbox and asks once before switching to Docker", async ({ page, request }) => { - await openConsole(page, request, "nodes", { sandbox: "none" }); + await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); await page.getByRole("button", { name: "Own machines" }).click(); const microsandbox = page.getByRole("button", { name: "microsandbox Recommended" }); const docker = page.getByRole("button", { name: "Docker", exact: true }); @@ -239,7 +243,7 @@ test("preselects microsandbox and asks once before switching to Docker", async ( await docker.click(); await confirm.getByRole("button", { name: "Use Docker" }).click(); await expect(sizeStep).toBeVisible(); - await page.getByRole("button", { name: "Back" }).click(); + await page.getByRole("button", { name: "Back", exact: true }).click(); await expect(docker).toHaveAttribute("aria-pressed", "true"); await docker.click(); await expect(sizeStep).toBeVisible(); @@ -247,13 +251,13 @@ test("preselects microsandbox and asks once before switching to Docker", async ( }); test("saves E2B without opening Add node, as it has no machines", async ({ page, request }) => { - await openConsole(page, request, "nodes", { sandbox: "none" }); + await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); 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: "Save configuration" }).click(); - await expect(page.getByRole("heading", { name: "Sandbox backend", level: 1 })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Sandbox configuration", level: 1 })).toBeVisible(); await expect(page.getByRole("dialog")).toHaveCount(0); }); @@ -261,7 +265,7 @@ test("edits only the saved backend, preserving a custom size and Runtime", async const runtime = { source_commit: "0".repeat(40), image_id: `sha256:${"a".repeat(64)}`, image_manifest_digest: `sha256:${"b".repeat(64)}`, microsandbox_ref: `oac-runtime@sha256:${"b".repeat(64)}`, runtime_sha256: "c".repeat(64), firmware_sha256: "d".repeat(64) }; const current = { resources: { cpus: 7, memory_mib: 8192 }, runtime }; - let deployment = { rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 0, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "docker", core_url: "https://core.example", reset: null, + let deployment = { installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "docker", core_url: "https://core.example", reset: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 0, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: current, specification_digest: "e".repeat(64), suspension: null }; let submitted: Record | null = null; @@ -273,7 +277,7 @@ test("edits only the saved backend, preserving a custom size and Runtime", async await route.fulfill({ json: deployment }); }); await page.route("**/core/v1/sandbox/nodes", (route) => route.fulfill({ json: { data: [] } })); - await openConsole(page, request, "nodes"); + await openConsole(page, request, "system?id=sandbox"); await page.getByRole("button", { name: "Change resources" }).click(); await expect(page.getByRole("button", { name: "Own machines" })).toHaveCount(0); await expect(page.getByRole("button", { name: "E2B cloud" })).toHaveCount(0); @@ -284,7 +288,7 @@ test("edits only the saved backend, preserving a custom size and Runtime", async }); 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 openConsole(page, request, "system?id=sandbox", { 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(); @@ -300,7 +304,13 @@ test("keeps the page usable when Core refuses a sandbox change, and shows Core's 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(); + const added = page.getByRole("dialog", { name: "Add node" }); + await expect(added).toBeVisible(); + await added.getByRole("button", { name: "Close dialog" }).click(); + await page.getByRole("button", { name: "System", exact: true }).click(); + await page.getByRole("button", { name: "Manage sandbox configuration", exact: true }).click(); + await page.getByRole("button", { name: "Configuration details", exact: true }).click(); + await expect(page.locator(".help-tip-popover").getByText("c0ffee000000", { exact: true })).toBeVisible(); }); test("renames a node and sets how many sandboxes run on it at once", async ({ page, request }) => { diff --git a/apps/web/e2e/provider-observations.spec.ts b/apps/web/e2e/provider-observations.spec.ts new file mode 100644 index 000000000..b3fd750d3 --- /dev/null +++ b/apps/web/e2e/provider-observations.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from "@playwright/test"; +import { expectManagementBoundary, openConsole, writes } from "./console"; + +test.afterEach(async ({ request }) => expectManagementBoundary(request)); + +test("default provider observations are optional details and recover only on Core success", async ({ page, request }) => { + let observation = { last_used_at: null as string | null, last_error_code: null as string | null, last_error_at: null as string | null }; + let fail = false; + await page.route("**/core/v1/harnesses", async (route) => { + if (fail) return route.fulfill({ status: 503, json: { error: { code: "internal_error", message: "Read unavailable", type: "server_error", param: null } } }); + const response = await route.fetch(); + const data = await response.json(); + for (const harness of data.data) if (harness.model_provider) Object.assign(harness.model_provider, observation); + await route.fulfill({ response, json: data }); + }); + await openConsole(page, request, "system"); + const card = page.getByRole("article", { name: "Codex", exact: true }); + await expect(card).not.toContainText("Last successful use"); + await card.getByRole("button", { name: "Usage details", exact: true }).click(); + const details = page.getByRole("dialog", { name: "Codex usage details", exact: true }); + await expect(details).toContainText("Not recorded"); + await details.getByRole("button", { name: "Close dialog", exact: true }).click(); + observation = { last_used_at: null, last_error_code: "authentication_error", last_error_at: "2026-09-28T08:01:00Z" }; + await page.getByRole("button", { name: "Refresh system configuration", exact: true }).click(); + await expect(card).toContainText("Recent error"); + await card.getByRole("button", { name: "Usage details", exact: true }).click(); + await expect(details).toContainText("The model provider rejected authentication"); + await expect(details).not.toContainText("authentication_error"); + await details.getByRole("button", { name: "Close dialog", exact: true }).click(); + observation.last_used_at = "2026-09-28T08:02:00Z"; + await page.getByRole("button", { name: "Refresh system configuration", exact: true }).click(); + await expect(card).not.toContainText("Recent error"); + await card.getByRole("button", { name: "Usage details", exact: true }).click(); + await expect(details).toContainText("None recorded"); + await expect(details).not.toContainText("The model provider rejected authentication"); + await details.getByRole("button", { name: "Close dialog", exact: true }).click(); + fail = true; + await page.getByRole("button", { name: "Refresh system configuration", exact: true }).click(); + await expect(card).toContainText("Observation unconfirmed"); + expect(await writes(request)).toEqual([]); +}); diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index b406bd4c7..1875a1458 100644 --- a/apps/web/e2e/public-url.spec.ts +++ b/apps/web/e2e/public-url.spec.ts @@ -7,7 +7,7 @@ test.afterEach(async ({ request }) => expectManagementBoundary(request)); test("sets up sandboxes with Core's address read-only, never sending it", async ({ page, request }) => { const bodies: string[] = []; page.on("request", (sent) => { if (sent.url().includes("/core/v1/sandbox/deployment")) bodies.push(sent.postData() ?? ""); }); - await openConsole(page, request, "nodes", { sandbox: "none" }); + await openConsole(page, request, "system?id=sandbox", { sandbox: "none" }); await page.getByRole("button", { name: "Own machines" }).click(); await page.getByRole("button", { name: "microsandbox Recommended" }).click(); await page.getByRole("button", { name: /^Standard/ }).click(); @@ -23,7 +23,7 @@ test("sets up sandboxes with Core's address read-only, never sending it", async }); test("explains an E2B rejection in the wizard, with the file to edit and the command to apply it", async ({ page, request }) => { - await openConsole(page, request, "nodes", { sandbox: "none", installation: "local" }); + await openConsole(page, request, "system?id=sandbox", { sandbox: "none", installation: "local" }); 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"); @@ -32,7 +32,7 @@ test("explains an E2B rejection in the wizard, with the file to edit and the com await expect(address).toContainText("Only the Core machine can reach this address"); await page.getByRole("button", { name: "Save configuration" }).click(); const rejection = page.locator(".wizard-rejection"); - await expect(rejection).toContainText("E2B sandboxes reach Core over the internet."); + await expect(rejection).toContainText("E2B sandboxes need an HTTPS public_url reachable from the internet."); await expect(rejection).toContainText("/opt/oac/config.json"); await expect(rejection).toContainText("sudo oac apply"); // Nothing was saved and nothing is uncertain: no dialog, and the wizard stays on its review. diff --git a/apps/web/e2e/sandbox-generation.spec.ts b/apps/web/e2e/sandbox-generation.spec.ts new file mode 100644 index 000000000..eafd8554b --- /dev/null +++ b/apps/web/e2e/sandbox-generation.spec.ts @@ -0,0 +1,325 @@ +import { expect, test, type Locator, type Page, type TestInfo } from "@playwright/test"; +import type { SandboxAllocation, SandboxDeployment, SandboxNode } from "@agents-core-web/agents-client"; + +import { expectManagementBoundary, failNext, openConsole, setDeployment, setNode, writes } from "./console"; + +const deploymentPath = "/core/v1/sandbox/deployment"; +const rollout = (page: Page) => page.getByRole("region", { name: "Configuration rollout", exact: true }); +const fact = (scope: Locator, label: string) => scope.locator("dt").filter({ hasText: new RegExp(`^${label}`) }).locator("..").locator("dd"); +async function inspectRollout(page: Page, values: Record) { + await rollout(page).getByRole("button", { name: "View rollout details", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Configuration rollout", exact: true }); + for (const [label, value] of Object.entries(values)) await expect(fact(dialog, label)).toHaveText(value); + await dialog.getByRole("button", { name: "Close dialog", exact: true }).click(); + await expect(dialog).toBeHidden(); +} +const deploymentRead = (page: Page) => read(page, deploymentPath); +async function read(page: Page, path: string): Promise { + const response = await page.request.get(new URL(path, page.url()).href); + expect(response.ok()).toBe(true); + return response.json() as Promise; +} +async function inventory(page: Page) { + const nodes = (await read<{ data: SandboxNode[] }>(page, "/core/v1/sandbox/nodes")).data; + const allocations = (await Promise.all(nodes.map((node) => read<{ data: SandboxAllocation[] }>(page, `/core/v1/sandbox/nodes/${node.id}/allocations`)))).flatMap((result) => result.data); + return { nodes, allocations }; +} +async function saveConfiguration(page: Page) { + const sent = page.waitForRequest((request) => request.method() === "PUT" && request.url().endsWith(deploymentPath)); + const response = page.waitForResponse((response) => response.request().method() === "PUT" && response.url().endsWith(deploymentPath)); + await page.getByRole("button", { name: "Save configuration", exact: true }).click(); + return { input: (await sent).postDataJSON(), response: await response }; +} +async function editE2B(page: Page, key?: string) { + await page.getByRole("button", { name: "Change resources", exact: true }).click(); + await expect(page.getByLabel("E2B API key", { exact: true })).toHaveValue(""); + if (key !== undefined) await page.getByLabel("E2B API key", { exact: true }).fill(key); + await page.getByRole("button", { name: "Next", exact: true }).click(); +} + +async function capture(page: Page, info: TestInfo, name: string, scope?: Locator) { + const path = info.outputPath(`${name}.png`); + if (scope) await scope.screenshot({ path }); + else await page.screenshot({ path, fullPage: true }); + await info.attach(name, { path, contentType: "image/png" }); +} + +test.afterEach(async ({ request }) => expectManagementBoundary(request)); + +test("online configuration retains existing resources and stops rapid polling when preparation settles", async ({ page, request }, info) => { + await page.setViewportSize({ width: 1280, height: 1000 }); + await page.emulateMedia({ reducedMotion: "reduce", colorScheme: "light" }); + const pending = new Set(); + page.on("request", (sent) => { if (sent.method() === "GET" && sent.url().endsWith(deploymentPath)) pending.add(sent); }); + page.on("requestfinished", (sent) => pending.delete(sent)); + page.on("requestfailed", (sent) => pending.delete(sent)); + await openConsole(page, request, "system?id=sandbox"); + await expect(rollout(page)).toBeVisible(); + const before = await inventory(page); + expect(before.allocations.length).toBeGreaterThan(0); + await setDeployment(request, { resources: { allocations: before.allocations.length, pending: 0 } }); + await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); + await page.getByRole("button", { name: "Change resources", exact: true }).click(); + await page.getByRole("button", { name: /^Large/ }).click(); + const saved = await saveConfiguration(page); + expect(saved.response.ok()).toBe(true); + expect(saved.input).toMatchObject({ provider: "docker", expected_generation: 1 }); + const current = await saved.response.json() as SandboxDeployment; + expect(current.generation).toBe(2); + expect(current.resources.allocations).toBe(before.allocations.length); + await inspectRollout(page, { "Core preparation": "Preparing configuration", "Previous-generation sandboxes": String(before.allocations.length) }); + const after = await inventory(page); + expect(after.nodes.map((node) => node.id)).toEqual(before.nodes.map((node) => node.id)); + expect(after.allocations).toEqual(before.allocations); + expect(after.nodes.map((node) => node.rollout.ready_generation)).toEqual(before.nodes.map((node) => node.rollout.ready_generation)); + + // Explicit Core observations settle preparation; old Session ownership remains. + await setNode(request, { id: "node-local", rollout: { state: "ready", ready_generation: 2 } }); + await setNode(request, { id: "node-gpu", rollout: { state: "failed", ready_generation: 1, diagnostic: "runtime_image_unavailable" } }); + await setNode(request, { id: "node-edge", rollout: { state: "unknown", ready_generation: 1 } }); + await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); + await expect(rollout(page)).toContainText("Needs attention"); + await inspectRollout(page, { "Core preparation": "No active preparation", "Previous-generation sandboxes": String(before.allocations.length), "Preparation failed": "1", "Target readiness unknown": "1" }); + await expect(page.getByRole("button", { name: "Change resources", exact: true })).toBeEnabled(); + // Watch longer than the five-second preparation interval. Retained resources + // are not a reason to keep that interval alive; ordinary thirty-second reads remain. + await expect.poll(() => pending.size).toBe(0); + const rapidRead = await page.waitForRequest((sent) => sent.method() === "GET" && sent.url().endsWith(deploymentPath), { timeout: 6500 }).then(() => true, () => false); + expect(rapidRead).toBe(false); + await inspectRollout(page, { "Target generation": "2" }); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + await capture(page, info, "generation-settled-en-light-1280"); + await page.emulateMedia({ colorScheme: "dark" }); + await page.getByRole("button", { name: "Language and appearance" }).click(); + await page.getByRole("menuitemradio", { name: "简体中文" }).click(); + const localized = page.getByRole("region", { name: "配置更新进度", exact: true }); + await expect(localized).toContainText("需要检查"); + await localized.getByRole("button", { name: "查看详情", exact: true }).click(); + const details = page.getByRole("dialog", { name: "配置更新进度", exact: true }); + await expect(fact(details, "Core 准备状态")).toHaveText("没有正在进行的准备任务"); + await expect(fact(details, "目标代次")).toHaveText("2"); + await expect(fact(details, "旧代次沙箱")).toHaveText(String(before.allocations.length)); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await capture(page, info, "generation-settled-zh-dark-1280"); + expect(await writes(request)).toEqual([`PUT ${deploymentPath}`]); +}); + +test("unknown target preparation preserves live old-generation service while an offline pin stays offline", async ({ page, request }, info) => { + await page.setViewportSize({ width: 1280, height: 1000 }); + await page.emulateMedia({ colorScheme: "light", reducedMotion: "reduce" }); + await openConsole(page, request, "system?id=sandbox", { fresh: true }); + await setDeployment(request, { generation: 2 }); + await setNode(request, { id: "node-local", online: true, provider_ready: true, rollout: { state: "unknown", ready_generation: 1 } }); + await setNode(request, { id: "node-edge", online: false, provider_ready: true, rollout: { state: "unknown", ready_generation: 1 } }); + await setNode(request, { id: "node-gpu", rollout: { state: "update_required", ready_generation: 1 } }); + await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); + await inspectRollout(page, { "Node software incompatible": "1" }); + await page.getByRole("button", { name: "Nodes", exact: true }).click(); + const serving = page.getByRole("row", { name: /core-01/ }); + await expect(serving).toContainText("Available"); + await expect(serving).toContainText("Target readiness unknown"); + const row = page.getByRole("row", { name: /edge-03/ }); + await expect(row).toContainText("Offline"); + await expect(row).not.toContainText("Ready for target"); + await page.getByRole("button", { name: "Open edge-03", exact: true }).click(); + await expect(page.getByRole("heading", { name: "edge-03", exact: true })).toBeVisible(); + const facts = page.locator(".resource-facts"); + await expect(facts.locator("dt").filter({ hasText: /^Serving generation/ }).locator("..").locator("dd")).toHaveText("1"); + await expect(facts.locator("dt").filter({ hasText: /^Target preparation/ }).locator("..").locator("dd")).toContainText("Target readiness unknown"); + await expect(facts.locator("dt").filter({ hasText: /^Status$/ }).locator("..")).toContainText("Offline"); + await page.getByRole("button", { name: "Overview", exact: true }).click(); + const setup = page.getByRole("region", { name: "Getting started" }).getByRole("listitem").filter({ hasText: "Get sandboxes ready" }); + await expect(setup).toContainText("Done"); + await expect(rollout(page)).toHaveCount(0); + await page.getByRole("button", { name: "System", exact: true }).click(); + await expect(rollout(page)).toHaveCount(0); + await page.getByRole("button", { name: "Manage sandbox configuration", exact: true }).click(); + await inspectRollout(page, { "Target generation": "2", "Target readiness unknown": "2", "Node software incompatible": "1" }); + await capture(page, info, "generation-configuration-summary-en", rollout(page)); + expect(await writes(request)).toEqual([]); +}); + +test("a generation-only change preserves mixed allocation ownership through failed inventory reads", async ({ page, request }) => { + await openConsole(page, request, "system?id=sandbox"); + await expect(rollout(page)).toBeVisible(); + let expected: { session: string; generation: string }[] = []; + await page.route("**/core/v1/sandbox/nodes/node-local/allocations", async (route) => { + const response = await route.fetch(); + const body = await response.json() as { data: SandboxAllocation[] }; + body.data = body.data.map((allocation, index) => ({ ...allocation, deployment_generation: index % 2 + 1 })); + expected = body.data.map((allocation) => ({ session: allocation.session_id, generation: String(allocation.deployment_generation) })); + await route.fulfill({ response, json: body }); + }); + await setDeployment(request, { generation: 2 }); + await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); + await inspectRollout(page, { "Target generation": "2" }); + await page.getByRole("button", { name: "Nodes", exact: true }).click(); + await page.getByRole("button", { name: "Open core-01", exact: true }).click(); + const table = page.getByRole("table", { name: "Sandbox allocations", exact: true }); + await expect(table.getByRole("columnheader", { name: "Configuration generation", exact: true })).toBeVisible(); + await expect.poll(() => [...new Set(expected.map((entry) => entry.generation))].sort()).toEqual(["1", "2"]); + const ownership = () => table.locator("tbody tr").evaluateAll((rows) => rows.map((row) => ({ + session: row.querySelector("th code")?.getAttribute("title"), + generation: row.querySelector("td")?.textContent, + }))); + await expect.poll(ownership).toEqual(expected); + await page.route("**/core/v1/sandbox/nodes", (route) => route.fulfill({ status: 503, json: { error: { type: "server_error", code: null, message: "Node inventory unavailable.", param: null } } })); + await setDeployment(request, { generation: 3 }); + const refreshed = page.waitForResponse((response) => response.request().method() === "GET" && response.url().endsWith(deploymentPath)); + await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); + expect((await (await refreshed).json()).generation).toBe(3); + await expect(page.getByText("Node state could not be read", { exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "core-01", exact: true })).toBeVisible(); + await expect.poll(ownership).toEqual(expected); + expect(await writes(request)).toEqual([]); +}); + +test("E2B omitted-key updates keep the saved key while explicit same-key replacements each advance generation", async ({ page, request }) => { + await openConsole(page, request, "system?id=sandbox", { sandbox: "e2b" }); + await expect(rollout(page)).toBeVisible(); + const initial = await deploymentRead(page); + await editE2B(page); + const omitted = await saveConfiguration(page); + expect(omitted.input).toMatchObject({ provider: "e2b", expected_generation: 1, e2b: { template: initial.e2b!.template } }); + expect(omitted.input.e2b).not.toHaveProperty("api_key"); + expect((await omitted.response.json()).generation).toBe(1); + await expect(page.getByRole("dialog", { name: "Change resources", exact: true })).toBeHidden(); + const key = "fixture-same-team-key"; + for (const generation of [1, 2]) { + await editE2B(page, key); + const explicit = await saveConfiguration(page); + expect(explicit.input).toMatchObject({ provider: "e2b", expected_generation: generation, e2b: { template: initial.e2b!.template, api_key: key } }); + const current = await explicit.response.json() as SandboxDeployment; + expect(current.generation).toBe(generation + 1); + expect(current.resources).toEqual(initial.resources); + await inspectRollout(page, { "Target generation": String(generation + 1) }); + } + const storage = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } })); + expect(storage).not.toContain(key); + expect(await page.content()).not.toContain(key); + expect(await writes(request)).toEqual(Array(3).fill(`PUT ${deploymentPath}`)); +}); + +test("a different E2B team leaves the committed configuration intact and requires a deliberate reset", async ({ page, request }) => { + await openConsole(page, request, "system?id=sandbox", { sandbox: "e2b" }); + await expect(rollout(page)).toBeVisible(); + const before = await deploymentRead(page); + await editE2B(page, "fixture-other-team-key"); + const rejected = await saveConfiguration(page); + expect(rejected.response.status()).toBe(409); + await expect(page.locator(".wizard-rejection")).toContainText("Reset before changing teams."); + await expect(page.locator(".wizard-rejection")).toContainText("the saved configuration is unchanged"); + expect(await deploymentRead(page)).toEqual(before); + await expect(page.getByRole("button", { name: "Save configuration", exact: true })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Keep saved key", exact: true })).toBeVisible(); + await expect(page.getByRole("dialog", { name: "Change resources", exact: true })).toBeVisible(); + await expect(page.getByRole("dialog", { name: "Couldn't confirm the sandbox change" })).toHaveCount(0); + expect(await writes(request)).toEqual([`PUT ${deploymentPath}`]); + await page.getByRole("button", { name: "Cancel editing", exact: true }).click(); + await page.getByRole("button", { name: "Reset deployment", exact: true }).click(); + const reset = page.getByRole("dialog", { name: "Reset sandbox deployment?", exact: true }); + await expect(reset).toContainText("Hosted Sessions will be archived permanently."); + expect(await writes(request)).toEqual([`PUT ${deploymentPath}`]); + await reset.getByRole("button", { name: "Back", exact: true }).click(); + await editE2B(page); + expect(await writes(request)).toEqual([`PUT ${deploymentPath}`]); +}); + + +test("uncertain configuration refresh and inventory retry preserve a draft until its generation changes", async ({ page, request }) => { + await openConsole(page, request, "system?id=sandbox"); + await expect(rollout(page)).toBeVisible(); + const initial = await deploymentRead(page); + const committedSize = await fact(page.getByRole("region", { name: "Deployment provider", exact: true }), "Each sandbox").first().innerText(); + await page.getByRole("button", { name: "Change resources", exact: true }).click(); + const edit = page.getByRole("dialog", { name: "Change resources", exact: true }); + await edit.getByRole("button", { name: /^Large/ }).click(); + const wizard = edit.getByRole("region", { name: "Change the sandbox configuration", exact: true }); + const draftSize = await fact(wizard, "Each sandbox").innerText(); + expect(draftSize).not.toBe(committedSize); + await failNext(request, { method: "PUT", path: deploymentPath, status: 503, message: "Configuration outcome is unconfirmed." }); + const result = await saveConfiguration(page); + expect(result.response.status()).toBe(503); + expect(result.input.expected_generation).toBe(initial.generation); + let failInventory = true; + await page.route("**/core/v1/sandbox/nodes", (route) => failInventory + ? route.fulfill({ status: 503, json: { error: { type: "server_error", code: null, message: "Node inventory unavailable.", param: null } } }) + : route.continue()); + await expect(edit).toContainText("The sandbox service is unavailable. Refresh to check the current state."); + await expect(edit).toContainText("Refresh sandbox state to confirm whether the change was saved before submitting again."); + const failedInventory = page.waitForResponse((response) => response.url().endsWith("/sandbox/nodes") && response.status() === 503); + await edit.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); + await failedInventory; + // Configuration stays authoritative even when the independent node inventory fails. + await expect(wizard.getByRole("heading", { name: "Review and save", exact: true })).toBeVisible(); + await expect(fact(wizard, "Each sandbox")).toHaveText(draftSize); + expect((await deploymentRead(page)).generation).toBe(initial.generation); + await expect(wizard.getByRole("button", { name: "Save configuration", exact: true })).toBeEnabled(); + expect(await writes(request)).toEqual([`PUT ${deploymentPath}`]); + failInventory = false; + const inventoryRecovered = page.waitForResponse((response) => response.url().endsWith("/sandbox/nodes") && response.ok()); + await edit.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); + await inventoryRecovered; + await expect(fact(wizard, "Each sandbox")).toHaveText(draftSize); + await expect(wizard.getByRole("button", { name: "Save configuration", exact: true })).toBeEnabled(); + expect(await writes(request)).toEqual([`PUT ${deploymentPath}`]); + await setDeployment(request, { generation: initial.generation + 1 }); + await edit.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); + await expect(edit).toBeHidden(); + await inspectRollout(page, { "Target generation": String(initial.generation + 1) }); + await expect(wizard).toHaveCount(0); + await expect(fact(page.getByRole("region", { name: "Deployment provider", exact: true }), "Each sandbox")).toHaveText(committedSize); + await page.getByRole("button", { name: "Change resources", exact: true }).click(); + await expect(wizard.getByRole("button", { name: /^Large/ })).toBeVisible(); + await expect(wizard.getByRole("heading", { name: "Review and save", exact: true })).toHaveCount(0); + expect(await writes(request)).toEqual([`PUT ${deploymentPath}`]); +}); + + +test("an observed installation change discards the old reset confirmation even at the same generation", async ({ page, request }) => { + await openConsole(page, request, "system?id=sandbox"); + await expect(rollout(page)).toBeVisible(); + // A genuine preparing projection enables normal five-second observation while + // the confirmation is open; the test never clicks through a modal overlay. + await setNode(request, { id: "node-local", rollout: { state: "preparing", ready_generation: 1 } }); + await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); + await inspectRollout(page, { "Core preparation": "Preparing configuration" }); + await page.getByRole("button", { name: "Reset deployment", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Reset sandbox deployment?", exact: true }); + await expect(dialog).toBeVisible(); + const initial = await deploymentRead(page); + await setDeployment(request, { installation_id: `${initial.installation_id}-replacement` }); + await expect(dialog).toHaveCount(0); + await inspectRollout(page, { "Target generation": String(initial.generation) }); + expect(await writes(request)).toEqual([]); + await page.getByRole("button", { name: "Reset deployment", exact: true }).click(); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Back", exact: true }).click(); + expect(await writes(request)).toEqual([]); +}); + + +test("closing an uncertain edit preserves the shared write block until a successful read", async ({ page, request }) => { + await openConsole(page, request, "system?id=sandbox"); + await page.getByRole("button", { name: "Change resources", exact: true }).click(); + const edit = page.getByRole("dialog", { name: "Change resources", exact: true }); + await edit.getByRole("button", { name: /^Large/ }).click(); + await failNext(request, { method: "PUT", path: deploymentPath, status: 503, message: "Configuration outcome is unconfirmed." }); + let failReads = true; + await page.route("**/core/v1/sandbox/deployment", (route) => route.request().method() === "GET" && failReads + ? route.fulfill({ status: 503, json: { error: { type: "server_error", code: null, message: "Deployment read unavailable.", param: null } } }) + : route.continue()); + const result = await saveConfiguration(page); + expect(result.response.status()).toBe(503); + await expect(edit).toContainText("The sandbox service is unavailable. Refresh to check the current state."); + await edit.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); + await expect(edit.getByRole("button", { name: "Save configuration", exact: true })).toBeDisabled(); + await edit.getByRole("button", { name: "Cancel editing", exact: true }).click(); + await expect(edit).toBeHidden(); + await expect(page.getByRole("button", { name: "Change resources", exact: true })).toBeDisabled(); + expect(await writes(request)).toEqual([`PUT ${deploymentPath}`]); + failReads = false; + await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); + await expect(page.getByRole("button", { name: "Change resources", exact: true })).toBeEnabled(); + expect(await writes(request)).toEqual([`PUT ${deploymentPath}`]); +}); diff --git a/apps/web/e2e/sandbox-reset.spec.ts b/apps/web/e2e/sandbox-reset.spec.ts index c7a302f32..c314cd666 100644 --- a/apps/web/e2e/sandbox-reset.spec.ts +++ b/apps/web/e2e/sandbox-reset.spec.ts @@ -13,6 +13,16 @@ const observedReset = (clear: "auto" | "force" = "auto"): SandboxReset => ({ remaining: { busy: 2, idle: 1, cleanup: 1, on_offline_nodes: 2, offline_nodes: [{ node_id: "offline-owned", name: "offline-build-worker", resources: 2 }] }, }); const progress = (page: Page) => page.getByRole("region", { name: "Reset in progress" }); +async function openConfiguration(page: Page) { + await page.getByRole("button", { name: "System", exact: true }).click(); + await page.getByRole("button", { name: "Manage sandbox configuration", exact: true }).click(); +} +async function expectEnrollmentBlocked(page: Page) { + await page.getByRole("button", { name: "Nodes", exact: true }).click(); + await expect(page.getByRole("button", { name: "Add node", exact: true })).toBeDisabled(); + await openConfiguration(page); +} + async function openReset(page: Page) { await page.getByRole("button", { name: "Reset deployment", exact: true }).click(); return page.getByRole("dialog", { name: "Reset sandbox deployment?", exact: true }); @@ -31,7 +41,7 @@ test.afterEach(async ({ request }) => { test("auto reset requires a bounded deadline, then preserves Core progress past that deadline", async ({ page, request }, info) => { await page.setViewportSize({ width: 1280, height: 1000 }); await page.emulateMedia({ colorScheme: "light", reducedMotion: "reduce" }); - await openConsole(page, request, "nodes"); + await openConsole(page, request, "system?id=sandbox"); await setDeployment(request, { resources: { allocations: 3, pending: 1 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); const dialog = await openReset(page); @@ -43,14 +53,17 @@ test("auto reset requires a bounded deadline, then preserves Core progress past expect(await writes(request)).toEqual([]); await entry.press("Enter"); await expect(dialog).toBeVisible(); - const deadline = dialog.getByLabel(/Force remaining work after \(seconds\)/); + const deadline = dialog.getByLabel(/Wait before forcing \(seconds\)/); await expect(deadline).toHaveValue("3600"); await deadline.fill("299"); await expect(dialog.getByRole("button", { name: "Reset deployment", exact: true })).toBeDisabled(); await deadline.fill("86401"); await expect(dialog.getByRole("button", { name: "Reset deployment", exact: true })).toBeDisabled(); await deadline.fill("300"); - await expect(dialog).toContainText("Archived Sessions cannot be resumed"); + await expect(dialog).toContainText("Hosted Sessions will be archived permanently."); + await dialog.getByRole("button", { name: "What reset affects", exact: true }).click(); + await expect(page.locator(".help-tip-popover")).toContainText("Archived Sessions cannot be resumed"); + await dialog.getByRole("button", { name: "What reset affects", exact: true }).click(); await expect(dialog).toContainText("self-hosted"); await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); await capture(page, info, "reset-confirm-en-light-1280"); @@ -63,14 +76,14 @@ test("auto reset requires a bounded deadline, then preserves Core progress past await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); await expect(progress(page)).toContainText("offline-build-worker"); await expect(progress(page)).toContainText("Busy work can finish until the deadline."); - await expect(page.getByRole("button", { name: "Add node", exact: true })).toBeDisabled(); + await expectEnrollmentBlocked(page); await expect(page.getByRole("heading", { name: "Where should sandboxes run?" })).toHaveCount(0); await capture(page, info, "reset-progress-en-light-1280"); expect(await writes(request)).toEqual([`POST ${resetPath}`]); }); test("escalation and cancellation require explicit confirmation and never undo previous clearing", async ({ page, request }) => { - await openConsole(page, request, "nodes"); + await openConsole(page, request, "system?id=sandbox"); await setDeployment(request, { resources: { allocations: 3, pending: 1 }, reset: observedReset() }); await page.reload(); await progress(page).getByRole("button", { name: "Force reset now" }).click(); @@ -97,7 +110,7 @@ test("escalation and cancellation require explicit confirmation and never undo p }); test("force reset requires confirmation and reconfiguration uses the completed generation once", async ({ page, request }) => { - await openConsole(page, request, "nodes"); + await openConsole(page, request, "system?id=sandbox"); await setDeployment(request, { resources: { allocations: 1, pending: 0 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); const dialog = await openReset(page); @@ -120,7 +133,7 @@ test("force reset requires confirmation and reconfiguration uses the completed g }); test("an applied reset with a lost response stays blocked through failed reads and is never replayed", async ({ page, request }) => { - await openConsole(page, request, "nodes"); + await openConsole(page, request, "system?id=sandbox"); await expect(page.getByRole("button", { name: "Reset deployment", exact: true })).toBeEnabled(); await setDeployment(request, { resources: { allocations: 1, pending: 0 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); @@ -135,6 +148,7 @@ test("an applied reset with a lost response stays blocked through failed reads a await dialog.getByRole("button", { name: "Reset deployment", exact: true }).click(); const uncertain = page.getByRole("dialog", { name: "Couldn't confirm the sandbox change" }); await expect(uncertain).toBeVisible(); + await expect(dialog).toHaveCount(0); await uncertain.getByRole("button", { name: "Refresh sandbox state" }).click(); await expect(page.getByRole("button", { name: "Reset deployment", exact: true })).toBeDisabled(); expect(await writes(request)).toEqual([`POST ${resetPath}`]); @@ -146,7 +160,7 @@ test("an applied reset with a lost response stays blocked through failed reads a }); test("a stale reset is refused until fresh state is reviewed, with no automatic resubmission", async ({ page, request }) => { - await openConsole(page, request, "nodes"); + await openConsole(page, request, "system?id=sandbox"); await expect(page.getByRole("button", { name: "Reset deployment", exact: true })).toBeEnabled(); await setDeployment(request, { generation: 2, resources: { allocations: 3, pending: 1 } }); const dialog = await openReset(page); @@ -165,7 +179,7 @@ test("a stale reset is refused until fresh state is reviewed, with no automatic // The administrator reviews a new confirmation before the next explicit POST. const reviewed = await openReset(page); - await expect(reviewed).toContainText("Archived Sessions cannot be resumed"); + await expect(reviewed).toContainText("Hosted Sessions will be archived permanently."); expect(await writes(request)).toEqual([`POST ${resetPath}`]); const resubmitted = page.waitForRequest((sent) => sent.method() === "POST" && sent.url().endsWith(resetPath)); await reviewed.getByRole("button", { name: "Reset deployment", exact: true }).click(); @@ -177,7 +191,7 @@ test("a stale reset is refused until fresh state is reviewed, with no automatic test("reset progress survives failed node and deployment reads with visible qualifications in Chinese dark mode", async ({ page, request }, info) => { await page.setViewportSize({ width: 1280, height: 1000 }); await page.emulateMedia({ colorScheme: "dark" }); - await openConsole(page, request, "nodes"); + await openConsole(page, request, "system?id=sandbox"); await setDeployment(request, { resources: { allocations: 3, pending: 1 }, reset: observedReset() }); await page.route("**/core/v1/sandbox/nodes", (route) => route.fulfill(unavailable)); await page.reload(); @@ -200,7 +214,7 @@ test("reset progress survives failed node and deployment reads with visible qual }); test("a pending reset survives navigation and a lost response cannot reopen stale write controls", async ({ page, request }) => { - await openConsole(page, request, "nodes"); + await openConsole(page, request, "system?id=sandbox"); await expect(page.getByRole("button", { name: "Reset deployment", exact: true })).toBeEnabled(); await setDeployment(request, { resources: { allocations: 1, pending: 0 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); @@ -228,21 +242,21 @@ test("a pending reset survives navigation and a lost response cannot reopen stal // A hash navigation keeps the application/query cache alive, unlike a reload. await page.evaluate(() => { window.location.hash = "overview"; }); await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible(); - await page.getByRole("button", { name: "Nodes", exact: true }).click(); + await openConfiguration(page); const resetButton = page.getByRole("button", { name: "Reset deployment", exact: true }); await expect(resetButton).toBeDisabled(); - await expect(page.getByRole("button", { name: "Add node", exact: true })).toBeDisabled(); + await expectEnrollmentBlocked(page); expect(Date.now() - cachedAt).toBeLessThan(30_000); expect(await writes(request)).toEqual([`POST ${resetPath}`]); release(); await responseLost; // Ownership survives routing, but a departed page's local error dialog must - // not be resurrected on the new Nodes instance when that request settles. + // not be resurrected on the new configuration page when that request settles. await expect(page.getByRole("dialog", { name: "Couldn't confirm the sandbox change" })).toHaveCount(0); await page.getByRole("button", { name: "Overview", exact: true }).click(); await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible(); - await page.getByRole("button", { name: "Nodes", exact: true }).click(); + await openConfiguration(page); await expect(resetButton).toBeDisabled(); // An explicit refresh also fails; neither navigation nor failure replays POST. await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); @@ -257,22 +271,22 @@ test("a pending reset survives navigation and a lost response cannot reopen stal } finally { release(); } }); -test("Nodes adopts the reset and completion learned on Overview before its cached node data expires", async ({ page, request }) => { +test("Nodes and configuration adopt reset and completion learned on Overview before cached node data expires", async ({ page, request }) => { await openConsole(page, request, "nodes"); - await expect(page.getByRole("button", { name: "Reset deployment", exact: true })).toBeEnabled(); await expect(page.getByRole("button", { name: "Add node", exact: true })).toBeEnabled(); + await openConfiguration(page); + await expect(page.getByRole("button", { name: "Reset deployment", exact: true })).toBeEnabled(); const cachedAt = Date.now(); await page.getByRole("button", { name: "Overview", exact: true }).click(); await setDeployment(request, { resources: { allocations: 3, pending: 1 }, reset: observedReset() }); await page.getByRole("button", { name: "Refresh", exact: true }).click(); await expect(page.getByRole("heading", { name: "Sandbox reset in progress" })).toBeVisible(); - await page.getByRole("button", { name: "Nodes", exact: true }).click(); + await expectEnrollmentBlocked(page); await expect(progress(page)).toContainText("offline-build-worker"); - await expect(page.getByRole("button", { name: "Add node", exact: true })).toBeDisabled(); await expect(page.getByRole("button", { name: "Change resources", exact: true })).toHaveCount(0); expect(Date.now() - cachedAt).toBeLessThan(30_000); - // No refresh click: the shared active reset starts bounded read-only polling on Nodes. + // No refresh click: shared active reset starts bounded read-only polling on configuration. const forced = observedReset("force"); forced.remaining = { busy: 0, idle: 0, cleanup: 1, on_offline_nodes: 1, offline_nodes: [{ node_id: "offline-owned", name: "offline-build-worker", resources: 1 }] }; await setDeployment(request, { resources: { allocations: 1, pending: 0 }, reset: forced }); @@ -285,6 +299,9 @@ test("Nodes adopts the reset and completion learned on Overview before its cache await page.getByRole("button", { name: "Refresh", exact: true }).click(); await expect(page.getByRole("heading", { name: "Sandbox reset in progress" })).toHaveCount(0); await page.getByRole("button", { name: "Nodes", exact: true }).click(); + await expect(page.getByText("Set up sandbox hosting in System before adding nodes.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Add node", exact: true })).toHaveCount(0); + await openConfiguration(page); await expect(page.getByRole("heading", { name: "Where should sandboxes run?" })).toBeVisible(); await expect(progress(page)).toHaveCount(0); expect(Date.now() - completedAt).toBeLessThan(30_000); @@ -293,7 +310,7 @@ test("Nodes adopts the reset and completion learned on Overview before its cache test("signing out clears pending reset ownership and a late response cannot overwrite the new login", async ({ page, request }) => { - await openConsole(page, request, "nodes"); + await openConsole(page, request, "system?id=sandbox"); await setDeployment(request, { resources: { allocations: 1, pending: 0 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); const dialog = await openReset(page); @@ -319,7 +336,7 @@ test("signing out clears pending reset ownership and a late response cannot over await page.getByLabel("Core key", { exact: true }).fill(FIXTURE_CORE_KEY); await page.getByRole("button", { name: "Sign in", exact: true }).click(); await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible(); - await page.getByRole("button", { name: "Nodes", exact: true }).click(); + await openConfiguration(page); await page.getByRole("button", { name: "Own machines" }).click(); await page.getByRole("button", { name: "microsandbox Recommended" }).click(); await page.getByRole("button", { name: /^Standard/ }).click(); @@ -335,10 +352,12 @@ test("signing out clears pending reset ownership and a late response cannot over await (await lateResponse).finished(); // The old generation-1 reset response cannot restore reset progress, block // this login, or show the old route's result/error dialog. + await openConfiguration(page); const newDialog = await openReset(page); await expect(newDialog).toBeVisible(); await newDialog.getByRole("button", { name: "Back", exact: true }).click(); await expect(progress(page)).toHaveCount(0); + await page.getByRole("button", { name: "Nodes", exact: true }).click(); await expect(page.getByRole("button", { name: "Add node", exact: true })).toBeEnabled(); expect(await writes(request)).toEqual([`POST ${resetPath}`, "POST /core/v1/sandbox/deployment"]); } finally { release(); } diff --git a/apps/web/e2e/sandbox-rollout-details.spec.ts b/apps/web/e2e/sandbox-rollout-details.spec.ts new file mode 100644 index 000000000..7736a555a --- /dev/null +++ b/apps/web/e2e/sandbox-rollout-details.spec.ts @@ -0,0 +1,51 @@ +import { expect, test, type Locator } from "@playwright/test"; +import { expectManagementBoundary, openConsole, writes } from "./console"; + +const fact = (scope: Locator, label: string) => scope.locator("dt").filter({ hasText: new RegExp(`^${label}`) }).locator("..").locator("dd"); + +test.afterEach(async ({ request }) => expectManagementBoundary(request)); + +test("keeps rollout summary brief and opens all Core observations in details", async ({ page, request }) => { + await page.route("**/core/v1/sandbox/deployment", async (route) => { + const response = await route.fetch(); + const deployment = await response.json(); + await route.fulfill({ response, json: { ...deployment, generation: 4, resources: { allocations: 8, pending: 0 }, rollout: { state: "settled", previous_generation_sandboxes: 8, nodes: { ready: 1, preparing: 0, failed: 2, update_required: 3, unknown: 4 } } } }); + }); + await openConsole(page, request, "system?id=sandbox"); + const summary = page.getByRole("region", { name: "Configuration rollout", exact: true }); + await expect(summary).toContainText("Needs attention"); + await expect(summary.locator("dl")).toHaveCount(0); + await expect(summary.getByText("Target generation", { exact: true })).toHaveCount(0); + const details = summary.getByRole("button", { name: "View rollout details", exact: true }); + await details.click(); + const dialog = page.getByRole("dialog", { name: "Configuration rollout", exact: true }); + await expect(fact(dialog, "Core preparation")).toHaveText("No active preparation"); + await expect(fact(dialog, "Target generation")).toHaveText("4"); + await expect(fact(dialog, "Previous-generation sandboxes")).toHaveText("8"); + await expect(fact(dialog, "Preparation failed")).toHaveText("2"); + await expect(fact(dialog, "Node software incompatible")).toHaveText("3"); + await expect(fact(dialog, "Target readiness unknown")).toHaveText("4"); + await page.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible(); + await expect(details).toBeFocused(); + expect(await writes(request)).toEqual([]); +}); + +test("labels unknown target readiness without declaring settled nodes ready", async ({ page, request }) => { + await page.route("**/core/v1/sandbox/deployment", async (route) => { + const response = await route.fetch(); + const deployment = await response.json(); + await route.fulfill({ response, json: { ...deployment, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 1 } } } }); + }); + await openConsole(page, request, "system?id=sandbox"); + const summary = page.getByRole("region", { name: "Configuration rollout", exact: true }); + await expect(summary).toContainText("Target readiness unknown"); + await expect(summary).not.toContainText("No active preparation"); + await page.getByRole("button", { name: "Language and appearance" }).click(); + await page.getByRole("menuitemradio", { name: "简体中文" }).click(); + const translated = page.getByRole("region", { name: "配置更新进度", exact: true }); + await expect(translated).toContainText("目标配置就绪状态未知"); + await translated.getByRole("button", { name: "查看详情", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "配置更新进度", exact: true })).toContainText("Core 准备状态"); + expect(await writes(request)).toEqual([]); +}); diff --git a/apps/web/e2e/wording.spec.ts b/apps/web/e2e/wording.spec.ts index bc5020980..3c342b83a 100644 --- a/apps/web/e2e/wording.spec.ts +++ b/apps/web/e2e/wording.spec.ts @@ -42,8 +42,8 @@ test("keeps English Session filters and actions visible at 1280 pixels", async ( test("names the E2B backend consistently and explains its two count sources", async ({ page, request }) => { await openConsole(page, request, "system", { sandbox: "e2b" }); - await page.getByRole("button", { name: "Change on the Sandbox backend page" }).click(); - await expect(page.getByRole("heading", { name: "Sandbox backend", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Manage sandbox configuration" }).click(); + await expect(page.getByRole("heading", { name: "Sandbox configuration", exact: true })).toBeVisible(); await page.getByRole("button", { name: "Sandbox metrics", exact: true }).click(); await expect(page.getByText(/These sources have different coverage and refresh separately/)).toBeVisible(); }); diff --git a/apps/web/src/ConsoleApp.tsx b/apps/web/src/ConsoleApp.tsx index b68812b63..debc7e9f8 100644 --- a/apps/web/src/ConsoleApp.tsx +++ b/apps/web/src/ConsoleApp.tsx @@ -12,13 +12,14 @@ import { SandboxMetricsPage } from "./features/metrics/SandboxMetricsPage"; import { ConsoleTourContext, ConsoleTourScreen } from "./features/onboarding/ConsoleTour"; import { withTransition } from "./features/onboarding/view-transition"; import { OverviewPage } from "./features/overview/OverviewPage"; +import { SandboxDeploymentPage } from "./features/sandbox/SandboxDeploymentPage"; import { SandboxManagerView } from "./features/sandbox/SandboxManagerView"; import { SessionLogPage } from "./features/sessions/SessionLogPage"; import { SessionPage } from "./features/sessions/SessionPage"; import { SkillsPage } from "./features/skills/SkillsPage"; import { SystemPage } from "./features/system/SystemPage"; import { VaultsPage } from "./features/vaults/VaultsPage"; -import { consoleDepth, ConsoleNavigationContext, hashWithParams, routeParamsFromHash, type ConsoleIntent, type RouteParams } from "./lib/console-navigation"; +import { consoleDepth, ConsoleNavigationContext, useConsoleNavigation, hashWithParams, routeParamsFromHash, type ConsoleIntent, type RouteParams } from "./lib/console-navigation"; import { consoleHashForView, consoleNavParent, consoleViewFromHash, type ConsoleView } from "./lib/console-routes"; import { ProjectsProvider, useProjects } from "./lib/projects"; import { collectionQuery, collections, filesCollection, queryClient, type CollectionSpec } from "./lib/queries"; @@ -40,6 +41,7 @@ function readLocation(): { view: ConsoleView; params: RouteParams; intent: Conso } function ConsolePage({ view }: { view: ConsoleView }) { + const { params } = useConsoleNavigation(); switch (view) { case "overview": return ; case "core-metrics": return ; @@ -54,7 +56,7 @@ function ConsolePage({ view }: { view: ConsoleView }) { case "vaults": return ; case "projects": return ; case "nodes": return ; - case "system": return ; + case "system": return params.id === "sandbox" ? : ; } } diff --git a/apps/web/src/components/ConsoleSidebar.tsx b/apps/web/src/components/ConsoleSidebar.tsx index 9f4925704..98cd9b6bd 100644 --- a/apps/web/src/components/ConsoleSidebar.tsx +++ b/apps/web/src/components/ConsoleSidebar.tsx @@ -1,7 +1,6 @@ import { Activity, Bot, - Cloud, Cpu, FileText, FolderKanban, @@ -20,7 +19,6 @@ import * as m from "motion/react-m"; import { useTranslation } from "react-i18next"; import { consoleNavGroups, type ConsoleView } from "../lib/console-routes"; -import { useSandboxProvider } from "../features/sandbox/sandbox-queries"; import { AppearanceMenu } from "./AppearanceMenu"; import { ConsoleAccountMenu } from "../features/first-run/ConsoleAccess"; @@ -55,7 +53,6 @@ export function ConsoleSidebar({ onGettingStarted: () => void; }) { const { t } = useTranslation("navigation"); - const provider = useSandboxProvider(); return (