From 5d2c04417825d3b2aee56c2dfb9d0f3e28539d28 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sun, 27 Sep 2026 20:35:07 +0000 Subject: [PATCH 01/16] Support sandbox generation changes and qualified rollout observations --- apps/web/DESIGN.md | 41 +++- apps/web/PRODUCT.md | 18 +- apps/web/e2e/console.ts | 4 +- apps/web/e2e/fixture-console.mjs | 57 ++++-- apps/web/e2e/nodes.spec.ts | 2 +- apps/web/e2e/sandbox-generation.spec.ts | 176 ++++++++++++++++++ .../src/features/fleet/FleetReadNotice.tsx | 20 ++ apps/web/src/features/fleet/fleet-model.ts | 5 + .../features/fleet/use-sandbox-fleet.test.tsx | 32 +++- .../src/features/fleet/use-sandbox-fleet.ts | 21 ++- .../src/features/metrics/CoreMetricsPage.tsx | 5 +- apps/web/src/features/metrics/MetricsView.css | 2 + .../features/metrics/SandboxMetricsPage.tsx | 15 +- .../features/metrics/sandbox-runtime.test.ts | 4 +- .../src/features/metrics/sandbox-runtime.ts | 4 + .../src/features/overview/FleetTopology.tsx | 11 +- .../src/features/overview/OverviewPage.tsx | 6 +- .../features/overview/getting-started.test.ts | 13 +- .../src/features/overview/getting-started.ts | 3 +- apps/web/src/features/sandbox/NodeDetail.tsx | 9 +- .../web/src/features/sandbox/NodeList.test.ts | 8 + apps/web/src/features/sandbox/NodeList.tsx | 3 + .../features/sandbox/NodeRolloutStatus.tsx | 19 ++ .../sandbox/SandboxDeploymentSettings.tsx | 20 +- .../features/sandbox/SandboxManagerView.css | 10 + .../features/sandbox/SandboxManagerView.tsx | 11 +- .../sandbox/SandboxRolloutSummary.test.tsx | 51 +++++ .../sandbox/SandboxRolloutSummary.tsx | 34 ++++ .../features/sandbox/SandboxSetupWizard.tsx | 57 +++--- .../features/sandbox/sandbox-queries.test.ts | 28 ++- .../src/features/sandbox/sandbox-queries.ts | 12 +- .../features/sandbox/sandbox-update.test.ts | 20 ++ .../src/features/sandbox/sandbox-update.ts | 12 ++ .../use-sandbox-manager-state.test.tsx | 9 +- .../sandbox/use-sandbox-manager-state.ts | 11 +- apps/web/src/features/system/SystemPage.tsx | 2 + apps/web/src/i18n/locales/en/metrics.ts | 5 + apps/web/src/i18n/locales/en/overview.ts | 4 + apps/web/src/i18n/locales/en/system.ts | 2 +- apps/web/src/i18n/locales/zh-CN/metrics.ts | 5 + apps/web/src/i18n/locales/zh-CN/overview.ts | 4 + apps/web/src/i18n/locales/zh-CN/system.ts | 2 +- apps/web/src/lib/locale-strings.ts | 41 +++- apps/web/src/lib/sandbox-labels.test.ts | 13 +- apps/web/src/lib/sandbox-labels.ts | 15 +- 45 files changed, 731 insertions(+), 115 deletions(-) create mode 100644 apps/web/e2e/sandbox-generation.spec.ts create mode 100644 apps/web/src/features/fleet/FleetReadNotice.tsx create mode 100644 apps/web/src/features/sandbox/NodeRolloutStatus.tsx create mode 100644 apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx create mode 100644 apps/web/src/features/sandbox/SandboxRolloutSummary.tsx create mode 100644 apps/web/src/features/sandbox/sandbox-update.test.ts create mode 100644 apps/web/src/features/sandbox/sandbox-update.ts diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 73d80fc76..164de9331 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -750,16 +750,42 @@ 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 +Use the existing compact fact panel 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 @@ -777,7 +803,8 @@ 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 diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 881c7d8f0..b8c548b20 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -117,8 +117,22 @@ 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. + 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: the Nodes entry becomes Sandbox backend, and Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud (running, starting, size, template build) instead of node capacity, with no node column 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/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 0c5b51198..fc0f8e4a7 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") { @@ -356,19 +356,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 +426,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."); @@ -568,11 +586,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/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index c41f9ba14..3a02249e9 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -261,7 +261,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; diff --git a/apps/web/e2e/sandbox-generation.spec.ts b/apps/web/e2e/sandbox-generation.spec.ts new file mode 100644 index 000000000..6d3bf1494 --- /dev/null +++ b/apps/web/e2e/sandbox-generation.spec.ts @@ -0,0 +1,176 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; +import type { SandboxAllocation, SandboxDeployment, SandboxNode } from "@agents-core-web/agents-client"; + +import { expectManagementBoundary, 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"); +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(); +} + +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, "nodes"); + 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 expect(rollout(page)).toContainText("Core is preparing the target configuration."); + await expect(fact(rollout(page), "Previous-generation sandboxes")).toHaveText(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("No active preparation"); + await expect(fact(rollout(page), "Previous-generation sandboxes")).toHaveText(String(before.allocations.length)); + await expect(fact(rollout(page), "Preparation failed")).toHaveText("1"); + await expect(fact(rollout(page), "Target readiness unknown")).toHaveText("1"); + await expect(page.getByRole("button", { name: "Change resources", exact: true })).toBeEnabled(); + await rollout(page).screenshot({ path: info.outputPath("generation-settled-with-old-resources.png") }); + // 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); + 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 }) => { + await openConsole(page, request, "nodes", { 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 expect(fact(rollout(page), "Node update required")).toHaveText("1"); + 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"); + expect(await writes(request)).toEqual([]); +}); + +test("a generation-only change preserves old allocation evidence through failed inventory reads", async ({ page, request }) => { + await openConsole(page, request, "nodes?id=node-local"); + const table = page.getByRole("table", { name: "Sandbox allocations", exact: true }); + await expect(table).toBeVisible(); + await expect(table.getByRole("columnheader", { name: "Configuration generation", exact: true })).toBeVisible(); + const rows = table.getByRole("row"); + const count = await rows.count(); + expect(count).toBeGreaterThan(1); + const original = await table.getByRole("rowheader").allTextContents(); + 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: 2 }); + await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); + 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(rows).toHaveCount(count); + expect(await table.getByRole("rowheader").allTextContents()).toEqual(original); + for (const row of (await rows.all()).slice(1)) await expect(row.getByRole("cell").first()).toHaveText("1"); + 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, "nodes", { 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 page.getByRole("button", { name: "Cancel editing", exact: true }).click(); + 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 expect(fact(rollout(page), "Target generation")).toHaveText(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, "nodes", { 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")).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("Archived Sessions cannot be resumed"); + 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}`]); +}); diff --git a/apps/web/src/features/fleet/FleetReadNotice.tsx b/apps/web/src/features/fleet/FleetReadNotice.tsx new file mode 100644 index 000000000..9b2bd7565 --- /dev/null +++ b/apps/web/src/features/fleet/FleetReadNotice.tsx @@ -0,0 +1,20 @@ +import { useTranslation } from "react-i18next"; + +import { ReadFailure } from "../../components/ReadFailure"; +import type { FleetState } from "./use-sandbox-fleet"; + +/** Retained observations stay tied to their original generation and read outcome. */ +export function FleetReadNotice({ state, onRetry }: { state: FleetState; onRetry: () => void }) { + const { t } = useTranslation("overview"); + if (state.status !== "ready") return null; + return <> + {state.error !== null ? : null} + {state.targetGeneration !== state.snapshot.deployment.generation + ?

{t("fleet.previousGeneration", { observed: state.snapshot.deployment.generation, target: state.targetGeneration })}

+ : null} + ; +} + +export function fleetObservationStale(state: FleetState): boolean { + return state.status === "ready" && (state.error !== null || state.targetGeneration !== state.snapshot.deployment.generation); +} diff --git a/apps/web/src/features/fleet/fleet-model.ts b/apps/web/src/features/fleet/fleet-model.ts index c7007ff35..c62946126 100644 --- a/apps/web/src/features/fleet/fleet-model.ts +++ b/apps/web/src/features/fleet/fleet-model.ts @@ -13,6 +13,11 @@ export function nodeHealth(node: SandboxNode): NodeHealth { return node.provider_ready && !node.diagnostic ? "available" : "degraded"; } +/** Live provider evidence is independent of target preparation and durable serving pins. */ +export function nodeServingReady(node: SandboxNode): boolean { + return node.online && node.provider_ready; +} + export interface CapacitySummary { nodes: number; online: number; diff --git a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx index 38a4eba9d..d81903d95 100644 --- a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx +++ b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx @@ -8,6 +8,7 @@ import { node } from "../overview/test-fixtures"; import type { SandboxConsoleConfig } from "../sandbox/console-config"; import { sandboxDeploymentQuery } from "../sandbox/sandbox-queries"; import { consoleConfigQuery, fleetQuery, type FleetSnapshot } from "./fleet-queries"; +import { FleetReadNotice } from "./FleetReadNotice"; import { fleetSnapshot, useSandboxFleet } from "./use-sandbox-fleet"; const configured: SandboxDeployment = { rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null }; @@ -16,16 +17,17 @@ function Probe() { const { state, deployment } = useSandboxFleet(); const snapshot = fleetSnapshot(state); const sandboxes = gettingStartedSteps({ fleet: state, sandboxReset: deployment.data ? deployment.data.reset !== null : undefined, localOnly: false, projects: [], sessions: 0, harnesses: [] }).sandboxes; - return
{snapshot ? `${snapshot.nodes.length} nodes / ${snapshot.deployment.resources.allocations} allocations` : "No current fleet evidence"}
; + return
{snapshot ? `${snapshot.nodes.length} nodes / ${snapshot.deployment.resources.allocations} allocations` : "No current fleet evidence"} {}} />
; } -function render(latest: SandboxDeployment, previous = configured) { +function render(latest: SandboxDeployment, previous = configured, failed = false) { const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const snapshot: FleetSnapshot = { deployment: previous, nodes: [node("n1")], allocations: [], loadedAt: 1 }; const config: SandboxConsoleConfig = { sandbox_admin: true, node_installer: false, node_installer_sha256: "", self_hosted_installer: false, self_hosted_installer_sha256: "" }; cache.setQueryData(consoleConfigQuery.queryKey, () => config); cache.setQueryData(fleetQuery(false).queryKey, snapshot); cache.setQueryData(sandboxDeploymentQuery.queryKey, latest); + if (failed) cache.getQueryCache().find({ queryKey: fleetQuery(false).queryKey })?.setState({ status: "error", error: new Error("inventory read failed") }); const html = renderToStaticMarkup(); cache.clear(); return html; @@ -43,11 +45,35 @@ describe("fleet evidence after deployment changes", () => { }); it("also rejects prior installation, owner and provider evidence", () => { - for (const change of [{ installation_id: "next" }, { owner_epoch: 2 }, { provider: "e2b" as const }, { generation: 2 }]) { + for (const change of [{ installation_id: "next" }, { owner_epoch: 2 }, { provider: "e2b" as const }]) { const html = render({ ...configured, ...change }); expect(html).toContain('data-fleet="loading"'); expect(html).not.toContain('data-sandbox="done"'); } expect(render(configured)).toContain('data-sandbox="done"'); }); + + it("retains older resource evidence across an online generation change with an explicit qualification", () => { + const html = render({ ...configured, generation: 2, rollout: { state: "preparing", previous_generation_sandboxes: 1, nodes: { ready: 0, preparing: 1, failed: 0, update_required: 0, unknown: 0 } } }); + expect(html).toContain('data-fleet="ready"'); + expect(html).toContain("1 nodes / 1 allocations"); + expect(html).toContain("configuration generation 1; the current target is 2"); + expect(html).toContain('data-sandbox="done"'); + }); + + it("discards inventory across reset start even when provider and generation match", () => { + const html = render({ ...configured, reset: { clear: "auto", requested_at: "2026-09-27T10:00:00Z", deadline_at: "2026-09-27T11:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 0, on_offline_nodes: 0, offline_nodes: [] } } }); + expect(html).toContain('data-fleet="loading"'); + expect(html).not.toContain("1 allocations"); + expect(html).toContain('data-sandbox="todo"'); + }); + + it("keeps both generation and read-failure qualifications when an online refresh fails", () => { + const html = render({ ...configured, generation: 2 }, configured, true); + expect(html).toContain("1 nodes / 1 allocations"); + expect(html).toContain("configuration generation 1; the current target is 2"); + expect(html).toContain("Retry"); + expect(html).toContain('data-sandbox="unknown"'); + }); + }); diff --git a/apps/web/src/features/fleet/use-sandbox-fleet.ts b/apps/web/src/features/fleet/use-sandbox-fleet.ts index f28706d95..2f1c59cb9 100644 --- a/apps/web/src/features/fleet/use-sandbox-fleet.ts +++ b/apps/web/src/features/fleet/use-sandbox-fleet.ts @@ -12,7 +12,7 @@ export type FleetState = /** The console has no sandbox administration credential. */ | { status: "unconfigured" } | { status: "loading" } - | { status: "ready"; snapshot: FleetSnapshot; refreshing: boolean; error: unknown | null } + | { status: "ready"; snapshot: FleetSnapshot; targetGeneration: number; refreshing: boolean; error: unknown | null } | { status: "failed"; error: unknown }; export const FLEET_REFRESH_MS = 30_000; @@ -36,20 +36,22 @@ export function useSandboxFleet({ poll = true, allocations = false }: { poll?: b refetchIntervalInBackground: false, }); - const differentDeployment = fleet.data !== undefined && deployment.data !== undefined && !fleetMatchesDeployment(fleet.data, deployment.data); + const differentDeployment = fleet.data !== undefined && deployment.data !== undefined && !fleetMatchesLifecycle(fleet.data, deployment.data); + const changedGeneration = fleet.data !== undefined && deployment.data !== undefined && fleet.data.deployment.generation !== deployment.data.generation; const { refetch: refetchFleet } = fleet; // A reset/configuration change can arrive through the faster deployment poll. - // Never attach the earlier generation's nodes or counts to that new truth. + // Compatible prior-generation inventory remains visible as an older observation. + // A reset or backend lifecycle change makes that earlier inventory invalid. useEffect(() => { - if (adminAvailable === true && differentDeployment) void refetchFleet(); - }, [adminAvailable, differentDeployment, deployment.data?.installation_id, deployment.data?.owner_epoch, deployment.data?.generation, deployment.data?.provider, refetchFleet]); + if (adminAvailable === true && (differentDeployment || changedGeneration)) void refetchFleet(); + }, [adminAvailable, differentDeployment, changedGeneration, deployment.data?.installation_id, deployment.data?.owner_epoch, deployment.data?.generation, deployment.data?.provider, deployment.data?.mode, deployment.data?.reset?.requested_at, refetchFleet]); let state: FleetState; if (configFailed) state = config.isFetching ? { status: "checking" } : { status: "failed", error: config.error }; else if (adminAvailable === null) state = { status: "checking" }; else if (!adminAvailable) state = { status: "unconfigured" }; else if (differentDeployment) state = fleet.isError && !fleet.isFetching ? { status: "failed", error: fleet.error } : { status: "loading" }; - else if (fleet.data) state = { status: "ready", snapshot: fleet.data, refreshing: fleet.isFetching, error: fleet.isError ? fleet.error : null }; + else if (fleet.data) state = { status: "ready", snapshot: fleet.data, targetGeneration: deployment.data?.generation ?? fleet.data.deployment.generation, refreshing: fleet.isFetching, error: fleet.isError ? fleet.error : null }; else if (fleet.isError && !fleet.isFetching) state = { status: "failed", error: fleet.error }; else state = { status: "loading" }; @@ -65,9 +67,10 @@ export function fleetSnapshot(state: FleetState): FleetSnapshot | null { return state.status === "ready" ? state.snapshot : null; } -/** Nodes and resource counts only describe the deployment generation they were read with. */ -function fleetMatchesDeployment(snapshot: FleetSnapshot, deployment: SandboxDeployment): boolean { +/** Online configuration updates preserve ownership; reset/backend lifecycle changes do not. */ +function fleetMatchesLifecycle(snapshot: FleetSnapshot, deployment: SandboxDeployment): boolean { const previous = snapshot.deployment; return previous.installation_id === deployment.installation_id && previous.owner_epoch === deployment.owner_epoch && - previous.generation === deployment.generation && previous.provider === deployment.provider; + previous.provider === deployment.provider && previous.mode === deployment.mode && + previous.reset?.requested_at === deployment.reset?.requested_at; } diff --git a/apps/web/src/features/metrics/CoreMetricsPage.tsx b/apps/web/src/features/metrics/CoreMetricsPage.tsx index 2ec728614..08ede6ea6 100644 --- a/apps/web/src/features/metrics/CoreMetricsPage.tsx +++ b/apps/web/src/features/metrics/CoreMetricsPage.tsx @@ -10,6 +10,7 @@ import { DashboardSkeleton, TableSkeleton } from "../../components/Skeleton"; import { useFailureToast } from "../../components/Toast"; import { EmptyState, Kpi, KpiStrip, PageBody, PageHeader, RefreshButton, Section, SegmentedControl, StatusDot, type Tone } from "../../components/console-ui"; import { formatBucket, formatBytes, formatClock, formatDuration, formatInteger, formatRelative, MISSING } from "../../lib/format"; +import { FleetReadNotice } from "../fleet/FleetReadNotice"; import { fleetSnapshot, useSandboxFleet } from "../fleet/use-sandbox-fleet"; import { coreMetricsQuery } from "./metrics-queries"; import "./MetricsView.css"; @@ -123,7 +124,8 @@ function CoreMetricsBody({ metrics }: { metrics: CoreMetrics }) { const integer = (value: number) => formatInteger(value, locale); const slotsFull = execution.slots_in_use !== null && execution.slots_total !== null && execution.slots_in_use >= execution.slots_total; // Sandbox nodes hold their own connection to Core, as daemons do; E2B deployments have none. - const fleet = fleetSnapshot(useSandboxFleet({ poll: true }).state); + const { state: fleetState, refresh: refreshFleet } = useSandboxFleet({ poll: true }); + const fleet = fleetSnapshot(fleetState); const nodeBacked = fleet ? fleet.deployment.mode === "nodes" : false; const online = fleet ? fleet.nodes.filter((node) => node.online).length : null; @@ -166,6 +168,7 @@ function CoreMetricsBody({ metrics }: { metrics: CoreMetrics }) { tone={nearLimit(process.rss_bytes, process.memory_limit_bytes, 0.85) ? "warning" : undefined} /> +
void deployment.refetch()} /> + {deployment.data ? navigate("nodes")} /> : null} + {cloud && fleet ? :
( setOpenNode(node.id)}> setOpenNode(node.id)} openLabel={t("sandbox.nodeDialog.openLabel", { name: node.name || node.id })} /> - +
@@ -200,6 +205,7 @@ export function SandboxMetricsPage() { node.id === openNode) ?? null} rows={rows} + stale={fleetObservationStale(fleetState)} load={runtimeState.load} range={range} onClose={() => setOpenNode(null)} @@ -445,7 +451,8 @@ function useLast(value: T | null): T | null { * A node in a dialog: the host figures it reports with each heartbeat, and * CPU and memory of the hosted sandboxes placed on it over the page's range. */ -function NodeDialog({ node, rows, load, range, onClose }: { +function NodeDialog({ node, rows, load, range, stale, onClose }: { + stale: boolean; node: SandboxNode | null; rows: readonly HostedRuntimeRow[]; load: HostedRuntimeLoad | null; @@ -493,8 +500,11 @@ function NodeDialog({ node, rows, load, range, onClose }: { > {shown ? (
+ {stale ?

{t("sandbox.nodeObservationStale")}

: null}
{t("sandbox.status")}
+
{t("sandbox.targetPreparation")}
+
{t("sandbox.servingGeneration")}
{shown.rollout.ready_generation ?? MISSING}{t("sandbox.servingGenerationHelp")}
{t("sandbox.slots")}
{formatInteger(shown.active, locale)} / {formatInteger(shown.max_active, locale)}
{shown.provider === "microsandbox" ?
{t("sandbox.suspended")}
{formatInteger(suspendedSandboxes(shown), locale)}
: null} {shown.provider === "microsandbox" ?
{t("sandbox.nodeDialog.retainedSlots")}
{formatInteger(shown.retained, locale)} / {formatInteger(shown.max_retained, locale)}
: null} @@ -548,6 +558,7 @@ function RuntimeDialog({ row, showNode, onClose }: { row: HostedRuntimeRow | nul
{tCommon("project.column")}
{showNode ?
{t("sandbox.node")}
{shown.node ? shown.node.name || shown.node.id : MISSING}
: null}
{t("sandbox.status")}
+
{t("sandbox.configurationGeneration")}
{shown.deploymentGeneration ?? MISSING}
{t("sandbox.uptime")}
{formatDuration(shown.uptimeSeconds)}
{/* Only E2B reports a sandbox's disk. */} {observation.disk ?
{t("sandbox.disk")}
{formatBytes(observation.disk.usage_bytes)} / {formatBytes(observation.disk.limit_bytes)}
: null} diff --git a/apps/web/src/features/metrics/sandbox-runtime.test.ts b/apps/web/src/features/metrics/sandbox-runtime.test.ts index abf38709f..de4690a72 100644 --- a/apps/web/src/features/metrics/sandbox-runtime.test.ts +++ b/apps/web/src/features/metrics/sandbox-runtime.test.ts @@ -65,10 +65,12 @@ describe("hosted Runtime projections", () => { }); it("places each Runtime on its host and derives uptime only from a current sample", () => { - const allocation = { id: "alloc_a", node_id: "n1", deployment_generation: 1 } as SandboxAllocation; + const allocation = { id: "alloc_a", node_id: "n1", deployment_generation: 3 } as SandboxAllocation; const rows = hostedRuntimeRows(load, "", { nodes: [node("n1", { name: "core-01" })], allocations: [allocation] }); expect(rows.map((row) => [row.observation.session_id, row.node?.name ?? null, row.uptimeSeconds])).toEqual([["a", "core-01", 600], ["b", null, 600], ["c", null, null]]); expect(hostedRuntimeRows(load, "", null)[0]?.node).toBeNull(); + expect(rows.map((row) => row.deploymentGeneration)).toEqual([3, null, null]); + expect(hostedRuntimeRows(load, "", null)[0]?.deploymentGeneration).toBeNull(); expect(matchesRuntime(rows[0]!, "nightly")).toBe(true); expect(matchesRuntime(rows[0]!, "core-01")).toBe(true); expect(matchesRuntime(rows[2]!, "nightly")).toBe(false); diff --git a/apps/web/src/features/metrics/sandbox-runtime.ts b/apps/web/src/features/metrics/sandbox-runtime.ts index c68cb4b16..372d47cef 100644 --- a/apps/web/src/features/metrics/sandbox-runtime.ts +++ b/apps/web/src/features/metrics/sandbox-runtime.ts @@ -130,6 +130,8 @@ export interface HostedRuntimeRow { session: AgentSession | null; /** The host of the Runtime's allocation, when the fleet lists it. */ node: SandboxNode | null; + /** Ownership generation from a matching allocation, never the deployment target or node pin. */ + deploymentGeneration: number | null; /** Seconds from the Runtime's start to its last sample. */ uptimeSeconds: number | null; } @@ -140,6 +142,7 @@ export function hostedRuntimeRows( fleet: { nodes: readonly SandboxNode[]; allocations: readonly SandboxAllocation[] } | null, ): HostedRuntimeRow[] { const nodes = new Map((fleet?.nodes ?? []).map((node) => [node.id, node])); + const generations = new Map((fleet?.allocations ?? []).map((allocation) => [allocation.id, allocation.deployment_generation])); const nodeOfAllocation = new Map((fleet?.allocations ?? []).map((allocation) => [allocation.id, nodes.get(allocation.node_id) ?? null])); return load.observations .filter((observation) => !projectId || observation.project_id === projectId) @@ -147,6 +150,7 @@ export function hostedRuntimeRows( observation, session: load.sessions.get(observation.session_id) ?? null, node: observation.instance.allocation_id ? nodeOfAllocation.get(observation.instance.allocation_id) ?? null : null, + deploymentGeneration: observation.instance.allocation_id ? generations.get(observation.instance.allocation_id) ?? null : null, uptimeSeconds: observation.status === "observed" && observation.started_at !== null && observation.observed_at !== null && observation.observed_at >= observation.started_at ? observation.observed_at - observation.started_at : null, diff --git a/apps/web/src/features/overview/FleetTopology.tsx b/apps/web/src/features/overview/FleetTopology.tsx index 4f6545b6e..c1398c148 100644 --- a/apps/web/src/features/overview/FleetTopology.tsx +++ b/apps/web/src/features/overview/FleetTopology.tsx @@ -5,12 +5,13 @@ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { ConsolePopover } from "../../components/console-popover"; -import { StatusDot, type Tone } from "../../components/console-ui"; +import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; import { formatBytes, formatDateTime, formatDuration, formatInteger, formatRelative, MISSING } from "../../lib/format"; import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; import { DiagnosticTip } from "../fleet/DiagnosticTip"; import { nodeHealth, suspendedSandboxes, type NodeHealth } from "../fleet/fleet-model"; import { coreMetricsQuery } from "../metrics/metrics-queries"; +import { NodeRolloutStatus } from "../sandbox/NodeRolloutStatus"; import { seconds } from "../sandbox/NodeList"; /** Row pitch of the node columns, in pixels. */ @@ -50,7 +51,7 @@ export function FleetTopology({ nodes, cloud, coreLabel, coreTone, stale, onOpen onOpenBackend?: () => void; coreLabel: string; coreTone: Tone; - /** The last refresh failed: keep the picture, stop implying live traffic. */ + /** Older generation or failed refresh: retain observations without implying live traffic. */ stale: boolean; onOpenNode: (node: SandboxNode) => void; onOpenSandboxMetrics: () => void; @@ -161,7 +162,7 @@ export function FleetTopology({ nodes, cloud, coreLabel, coreTone, stale, onOpen } > - + ); })} @@ -178,7 +179,7 @@ function Fact({ label, children }: { label: string; children: ReactNode }) { } /** A node at a glance: reachability (with the reason a degraded provider is not ready), sandbox slots and what the node has left. */ -function NodeGlance({ node, health }: { node: SandboxNode; health: NodeHealth }) { +function NodeGlance({ node, health, stale }: { node: SandboxNode; health: NodeHealth; stale: boolean }) { const { t, i18n } = useTranslation("overview"); const locale = i18n.resolvedLanguage; const now = Math.floor(Date.now() / 1000); @@ -193,6 +194,8 @@ function NodeGlance({ node, health }: { node: SandboxNode; health: NodeHealth }) {diagnostic ? : null} + + {node.rollout.ready_generation ?? MISSING}{t("fleet.servingGenerationHelp")} {seen === null ? t("fleet.facts.never") : formatRelative(seen, now, locale)} {count(node.active)}/ {count(node.max_active)} {node.provider === "microsandbox" ? {count(suspendedSandboxes(node))} : null} diff --git a/apps/web/src/features/overview/OverviewPage.tsx b/apps/web/src/features/overview/OverviewPage.tsx index 8d0a514be..5fe9ee541 100644 --- a/apps/web/src/features/overview/OverviewPage.tsx +++ b/apps/web/src/features/overview/OverviewPage.tsx @@ -26,6 +26,8 @@ import { useConsoleNavigation } from "../../lib/console-navigation"; import { formatClock, formatCompact, formatInteger, formatPercent, formatRelative, MISSING } from "../../lib/format"; import { ProjectName, useProjects } from "../../lib/projects"; import { capacitySummary, coreStatus, type CoreStatus } from "../fleet/fleet-model"; +import { SandboxRolloutSummary } from "../sandbox/SandboxRolloutSummary"; +import { FleetReadNotice, fleetObservationStale } from "../fleet/FleetReadNotice"; import { SandboxResetNotice } from "../fleet/SandboxResetNotice"; import { fleetSnapshot, useSandboxFleet, type FleetSnapshot, type FleetState } from "../fleet/use-sandbox-fleet"; import { type InProject } from "../metrics/project-sessions"; @@ -173,6 +175,8 @@ export function OverviewPage() { /> void deployment.refetch()} /> + {deployment.data ? navigate("nodes")} /> : null} + {installation.isError ? void installation.refetch()} partial={installation.data !== undefined} /> : null} void installation.refetch()} /> @@ -349,7 +353,7 @@ function FleetCard({ fleetState, core, localOnly }: { fleetState: FleetState; co onOpenBackend={() => navigate("nodes")} coreLabel={t(`coreStatus.${core}`)} coreTone={coreTone[core]} - stale={fleetState.status === "ready" && fleetState.error !== null} + stale={fleetObservationStale(fleetState)} onOpenNode={(node) => navigate("nodes", { id: node.id })} onOpenSandboxMetrics={() => navigate("sandbox-metrics")} onOpenCoreMetrics={() => navigate("core-metrics")} diff --git a/apps/web/src/features/overview/getting-started.test.ts b/apps/web/src/features/overview/getting-started.test.ts index 0fcba8d80..8adba7616 100644 --- a/apps/web/src/features/overview/getting-started.test.ts +++ b/apps/web/src/features/overview/getting-started.test.ts @@ -9,12 +9,23 @@ const deployment = (overrides: Partial = {}): SandboxDeployme rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, suspension: null, ...overrides, }); -const fleet = (value: SandboxDeployment, nodes = [node("n1")]): FleetState => ({ status: "ready", snapshot: { deployment: value, nodes, allocations: [], loadedAt: 0 }, refreshing: false, error: null }); +const fleet = (value: SandboxDeployment, nodes = [node("n1")]): FleetState => ({ status: "ready", snapshot: { deployment: value, nodes, allocations: [], loadedAt: 0 }, targetGeneration: value.generation, refreshing: false, error: null }); const sandboxes = (state: FleetState) => gettingStartedSteps({ sandboxReset: false, fleet: state, localOnly: false, projects: [], sessions: 0, harnesses: [] }).sandboxes; const provider = { object: "core.model_provider", protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true, updated_at: "2026-09-25T00:00:00Z" } as const; const harness = (id: CoreHarness["id"], fields: Partial = {}): CoreHarness => ({ object: "core.harness", id, enabled: true, default: false, model_provider: null, ...fields }); describe("Getting started steps", () => { + it("uses live provider readiness independently of target state or a durable pin", () => { + for (const state of ["preparing", "failed", "update_required", "unknown"] as const) { + const serving = node("n1", { rollout: { state, ready_generation: 1 } }); + expect(sandboxes(fleet(deployment({ generation: 2 }), [serving])).state).toBe("done"); + expect(sandboxes(fleet(deployment({ generation: 2 }), [{ ...serving, provider_ready: false }])).state).toBe("todo"); + } + expect(sandboxes(fleet(deployment(), [node("n1", { online: false, rollout: { state: "unknown", ready_generation: 1 } })])).state).toBe("todo"); + expect(sandboxes(fleet(deployment(), [node("n1", { rollout: { state: "unknown", ready_generation: 1 } })])).state).toBe("done"); + expect(sandboxes(fleet(deployment(), [node("n1", { provider_ready: false, rollout: { state: "preparing", ready_generation: null } })])).state).toBe("todo"); + }); + it("does not report ready during reset or an unreadable deployment, even with ready nodes", () => { for (const backend of ["docker", "e2b"] as const) { const input = { fleet: fleet(deployment({ provider: backend })), localOnly: false, projects: [project("p")], sessions: 1, diff --git a/apps/web/src/features/overview/getting-started.ts b/apps/web/src/features/overview/getting-started.ts index 36f3a8436..1d1758964 100644 --- a/apps/web/src/features/overview/getting-started.ts +++ b/apps/web/src/features/overview/getting-started.ts @@ -1,6 +1,7 @@ import type { CoreHarness } from "@agents-core-web/agents-client"; import { type Project } from "../../lib/admin-view"; +import { nodeServingReady } from "../fleet/fleet-model"; import { type FleetState } from "../fleet/use-sandbox-fleet"; import { templateBuildStatus } from "../sandbox/deployment-specification"; @@ -70,7 +71,7 @@ function sandboxStep(fleet: FleetState): GettingStartedSteps["sandboxes"] { if (deployment.provider === "e2b") { return { state: templateBuildStatus(deployment.e2b?.template_build) === "notReady" ? "todo" : "done", action: "nodes", cloud: true }; } - if (nodes.some((node) => node.online && node.provider_ready)) return { state: "done", action: "nodes", cloud: false }; + if (nodes.some(nodeServingReady)) return { state: "done", action: "nodes", cloud: false }; return { state: "todo", action: nodes.length ? "nodes" : "add-node", cloud: false }; } diff --git a/apps/web/src/features/sandbox/NodeDetail.tsx b/apps/web/src/features/sandbox/NodeDetail.tsx index b0423fd0f..24ed17056 100644 --- a/apps/web/src/features/sandbox/NodeDetail.tsx +++ b/apps/web/src/features/sandbox/NodeDetail.tsx @@ -8,6 +8,7 @@ import { formatDateTime, formatInteger, formatRelative, formatSpan, MISSING } fr import { nodeProviderDiagnostic, sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic"; import { sandboxStateLabel } from "../../lib/sandbox-labels"; import { DiagnosticTip } from "../fleet/DiagnosticTip"; +import { NodeRolloutStatus } from "./NodeRolloutStatus"; import { phaseTiming } from "./allocation-phase"; import { nodeState, NodeStatus, OldAddressHint, seconds } from "./NodeList"; @@ -36,11 +37,12 @@ function PhaseTime({ allocation, retentionSeconds, now }: { allocation: SandboxA return {text}; } -export function NodeDetail({ node, allocations, coreUrl, stale, suspension }: { +export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale, suspension }: { node: SandboxNode; allocations: readonly SandboxAllocation[]; /** The deployment's address; a node enrolled with another one is on an old address. */ coreUrl: string; + targetGeneration?: number; stale: boolean; /** The deployment's idle suspension policy; only microsandbox has one. */ suspension: SandboxDeployment["suspension"]; @@ -75,6 +77,9 @@ export function NodeDetail({ node, allocations, coreUrl, stale, suspension }: { {node.last_seen_at ? formatRelative(seconds(node.last_seen_at), now, locale) : t("Never")}
+
{t("Target generation")}
{targetGeneration ?? MISSING}
+
{t("Target preparation")}
+
{t("Serving generation")}{t("The saved serving generation is not proof that this node is online, ready or has free capacity.")}
{node.rollout.ready_generation ?? MISSING}
{t("Added")}
{formatDateTime(seconds(node.created_at), locale)}
@@ -100,6 +105,7 @@ export function NodeDetail({ node, allocations, coreUrl, stale, suspension }: { {t("Session")} + {t("Configuration generation")} {t("Recorded state")} {t("Recorded compute")} {/* Only microsandbox changes compute phase; under Docker it is always disabled. */} @@ -112,6 +118,7 @@ export function NodeDetail({ node, allocations, coreUrl, stale, suspension }: { {own.map((allocation) => ( + {allocation.deployment_generation} {sandboxStateLabel(allocation.state, shortLocale)} {allocation.compute_phase ? sandboxStateLabel(allocation.compute_phase, shortLocale) : MISSING} {suspension ? : null} diff --git a/apps/web/src/features/sandbox/NodeList.test.ts b/apps/web/src/features/sandbox/NodeList.test.ts index 6750fd415..338fafef1 100644 --- a/apps/web/src/features/sandbox/NodeList.test.ts +++ b/apps/web/src/features/sandbox/NodeList.test.ts @@ -9,6 +9,14 @@ const allocation = (nodeId: string, diagnostic: SandboxAllocation["diagnostic"]) const core = "https://core.example"; describe("node state", () => { + it("keeps live serving health independent of target preparation and durable pins", () => { + for (const state of ["unknown", "preparing", "failed", "update_required"] as const) { + const serving = node("a", { online: true, provider_ready: true, rollout: { state, ready_generation: 1 } }); + expect(nodeState(serving, [], false, core)).toBe("available"); + expect(nodeState({ ...serving, online: false }, [], false, core)).toBe("offline"); + } + }); + it("reports stale data, an old address and reachability before anything else", () => { expect(nodeState(node("a", { online: false }), [], true, core)).toBe("unconfirmed"); expect(nodeState(node("a", { core_url: "https://core-old.example" }), [], false, core)).toBe("old_address"); diff --git a/apps/web/src/features/sandbox/NodeList.tsx b/apps/web/src/features/sandbox/NodeList.tsx index 11498f163..69195bfb2 100644 --- a/apps/web/src/features/sandbox/NodeList.tsx +++ b/apps/web/src/features/sandbox/NodeList.tsx @@ -9,6 +9,8 @@ import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; import { DiagnosticTip } from "../fleet/DiagnosticTip"; import { nodeHealth, suspendedSandboxes } from "../fleet/fleet-model"; +import { NodeRolloutStatus } from "./NodeRolloutStatus"; + export type NodeState = "unconfirmed" | "old_address" | "offline" | "degraded" | "attention" | "available"; /** @@ -103,6 +105,7 @@ export function NodeList({ nodes, allocations, coreUrl, stale, disabled, suspend {diagnostic ? : null} + {state === "old_address" ? : null} {node.active} / {node.max_active} diff --git a/apps/web/src/features/sandbox/NodeRolloutStatus.tsx b/apps/web/src/features/sandbox/NodeRolloutStatus.tsx new file mode 100644 index 000000000..ed78f889b --- /dev/null +++ b/apps/web/src/features/sandbox/NodeRolloutStatus.tsx @@ -0,0 +1,19 @@ +import type { SandboxNode, SandboxNodeRollout } from "@agents-core-web/agents-client"; +import { useTranslation } from "react-i18next"; +import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; +import type { MessageKey } from "../../lib/locale-strings"; +import { DiagnosticTip } from "../fleet/DiagnosticTip"; + +const labels: Record = { ready: "Ready for target", preparing: "Preparing target", failed: "Preparation failed", update_required: "Node update required", unknown: "Target readiness unknown" }; +const tones: Record = { ready: "ok", preparing: "neutral", failed: "warning", update_required: "warning", unknown: "neutral" }; + +/** A serving pin is historical ownership, not proof of a live connection or capacity. */ +export function NodeRolloutStatus({ node, stale = false }: { node: SandboxNode; stale?: boolean }) { + const { t } = useTranslation("sandbox"); + const state = stale || !node.online ? "unknown" : node.rollout.state; + return + + {state === "failed" && node.rollout.diagnostic ? : null} + {state === "update_required" ? {t("Update this node's software to support the target generation. Existing serving ownership is retained.")} : null} + ; +} diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx index 65ddbe8dd..c753db09d 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx @@ -1,11 +1,12 @@ import { useState } from "react"; -import type { InitializeSandboxDeployment, SandboxDeployment, StartSandboxReset } from "@agents-core-web/agents-client"; +import type { UpdateSandboxDeployment, SandboxDeployment, StartSandboxReset } from "@agents-core-web/agents-client"; import { useTranslation } from "react-i18next"; import { HelpTip } from "../../components/console-ui"; import { formatBytes, formatPeriod, MISSING } from "../../lib/format"; import type { MessageKey } from "../../lib/locale-strings"; import { sandboxProviderLabel } from "../../lib/sandbox-labels"; import { sandboxSize, templateBuildSize, templateBuildStatus } from "./deployment-specification"; +import { SandboxRolloutSummary } from "./SandboxRolloutSummary"; import { SandboxResetControls } from "./SandboxResetControls"; import { SandboxSetupWizard } from "./SandboxSetupWizard"; @@ -18,7 +19,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset fresh: boolean; onReset: (input: StartSandboxReset) => Promise; onCancelReset: (expectedGeneration: number) => Promise; - onUpdate: (input: InitializeSandboxDeployment) => Promise; + onUpdate: (input: UpdateSandboxDeployment) => Promise; }) { const { t, i18n } = useTranslation("sandbox"); const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; @@ -29,7 +30,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset const build = deployment.e2b?.template_build; const buildSize = templateBuildSize(deployment); const sizeLabel = (value: { cpus: number; memory_mib: number }) => t("{{cpus}} CPU · {{memory}}", { cpus: value.cpus, memory: formatBytes(value.memory_mib * MIB) }); - const clean = fresh && !deployment.reset && deployment.resources?.allocations === 0 && deployment.resources?.pending === 0; + const canEdit = fresh && !deployment.reset; return

{t("Deployment provider")}

@@ -60,23 +61,24 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset
{t("E2B credential")}
{t(deployment.e2b?.credential_configured ? "Configured" : "Not configured")}
: null} + {!deployment.reset ? <>
- - {t("Editing keeps the current provider and requires zero allocated resources and pending environments. It is not an online upgrade.")} + + {t("Changes keep this backend and existing Sessions and nodes. Core prepares the new target for future work; placement may continue on qualified earlier generations.")}
- {!clean ?

{t("Changes require Core to confirm that no hosted resources remain.")}

: null} + {changing ? <> -

{t(deployment.provider === "e2b" ? "Saving changes only this provider's configuration; it does not confirm execution readiness." : "Saving retires existing node identities and enrollment commands. Add the nodes again after saving.")}

- +

{t("Existing sandboxes keep their configuration generation. Saving does not move them or prove the target is ready.")}

+ : null} : null} diff --git a/apps/web/src/features/sandbox/SandboxManagerView.css b/apps/web/src/features/sandbox/SandboxManagerView.css index 3eeeb1eef..852ddad34 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.css +++ b/apps/web/src/features/sandbox/SandboxManagerView.css @@ -110,3 +110,13 @@ .sandbox-reset-options > label:not(.field) { display: flex; align-items: center; gap: 8px; } .sandbox-reset-options input[type="number"] { max-width: 180px; } .sandbox-reset-dialog p { line-height: 1.6; } + +/* Shared rollout summaries keep target facts ahead of the secondary node partition. */ +.sandbox-rollout { min-width: 0; gap: 12px; } +.sandbox-rollout h3 { margin: 0; font-size: 13px; font-weight: 600; } +.sandbox-rollout p { margin: 0; font-size: 13px; color: var(--fg-muted); } +.sandbox-rollout > .text-action { align-self: flex-start; } +.sandbox-rollout-compact .sandbox-summary { display: flex; flex-wrap: wrap; gap: 8px 24px; } +.sandbox-rollout-compact .sandbox-summary > div { display: flex; align-items: baseline; gap: 6px; } +.sandbox-rollout-compact .sandbox-summary dt { margin: 0; } +.sandbox-rollout-nodes { border-top: 1px solid var(--line); padding-top: 12px; } diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index b6fcc0c7e..55f28dcf8 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; -import { type InitializeSandboxDeployment, type SandboxDeployment, type SandboxNode, type StartSandboxReset } from "@agents-core-web/agents-client"; +import { type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxDeployment, type SandboxNode, type StartSandboxReset } from "@agents-core-web/agents-client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Pencil, Plus, Server, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -64,7 +64,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const { params, navigate, back: goBack } = useConsoleNavigation(); const client = sandboxAdmin; const queryClient = useQueryClient(); - const { ownership, deploymentQuery, query, deployment, compatible, inventoryLoading, snapshot } = useSandboxManagerState(); + const { ownership, deploymentQuery, query, deployment, compatible, inventoryOlder, inventoryLoading, snapshot } = useSandboxManagerState(); const { refetch: refetchSnapshot } = query; const installation = useQuery(installationQuery); const localOnly = installation.data?.local_only === true; @@ -143,7 +143,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig async function initialize(input: InitializeSandboxDeployment) { if (await changeDeployment((signal) => client.initializeDeployment(input, { signal }), true) && input.provider !== "e2b" && !localOnly) setAdding(true); } - async function update(input: InitializeSandboxDeployment) { + async function update(input: UpdateSandboxDeployment) { await changeDeployment((signal) => client.updateDeployment({ ...input, expected_generation: snapshot!.deployment.generation }, { signal }), true); } const startReset = (input: StartSandboxReset) => changeDeployment((signal) => client.startReset(input, { signal })); @@ -185,7 +185,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig } finally { if (!controller.signal.aborted) setRemoving(false); } } - const nodesConfirmed = confirmed && compatible && !query.isError && !snapshot?.nodesError; + const nodesConfirmed = confirmed && compatible && !inventoryOlder && !query.isError && !snapshot?.nodesError; const nodes = snapshot?.nodes ?? []; const allocations = snapshot?.allocations ?? []; const hostedNodes = Boolean(snapshot?.deployment.provider && snapshot.deployment.provider !== "e2b"); @@ -206,6 +206,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const status = <> {!snapshot && (loading || deploymentQuery.isPending) ?

{t("Loading sandbox state…")}

: null} + {inventoryOlder ?

{t("Node observations are from an earlier configuration generation. Refreshing does not change their owned sandboxes.")}

: null} {snapshot?.nodesError ? : null} {snapshot && deploymentQuery.isError ?

{t("Refresh failed; showing the last loaded state.")}

: null} {setupNeedsRefresh ?

{t("Refresh sandbox state to confirm whether the change was saved before submitting again.")}

: null} @@ -250,7 +251,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig />
{status} - {selected ? : snapshot && nodesConfirmed && !loading ? ( + {selected ? : snapshot && nodesConfirmed && !loading ? ( {t("Back")}} /> ) : null}
diff --git a/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx b/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx new file mode 100644 index 000000000..a20d728e2 --- /dev/null +++ b/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx @@ -0,0 +1,51 @@ +import type { SandboxDeployment } from "@agents-core-web/agents-client"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { node } from "../overview/test-fixtures"; +import { SandboxRolloutSummary } from "./SandboxRolloutSummary"; +import { NodeRolloutStatus } from "./NodeRolloutStatus"; +import { SandboxDeploymentSettings } from "./SandboxDeploymentSettings"; + +const deployment: SandboxDeployment = { installation_id: "i", owner_epoch: 1, generation: 4, provider: "docker", mode: "nodes", core_url: "https://core.example", resources: { allocations: 8, pending: 2 }, reset: null, suspension: null, + rollout: { state: "settled", previous_generation_sandboxes: 8, nodes: { ready: 1, preparing: 0, failed: 2, update_required: 3, unknown: 4 } } }; + +describe("authoritative configuration rollout", () => { + it("shows settled with retained work and failed/unknown nodes without claiming readiness", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("No active preparation"); + expect(html).toContain("Previous-generation sandboxes
8"); + expect(html).toContain("Preparation failed
2"); + expect(html).toContain("Node update required
3"); + expect(html).toContain("Target readiness unknown
4"); + expect(html).not.toContain("Core is preparing the target configuration."); + expect(html).toContain("Review affected nodes"); + }); + + it("qualifies stale preparation and keeps routine compact summaries quiet", () => { + const preparing = renderToStaticMarkup(); + expect(preparing).toContain("last confirmed observations"); + expect(preparing).toContain("Core is preparing the target configuration."); + const quiet = renderToStaticMarkup(); + expect(quiet).toContain("Target generation
4"); + expect(quiet).not.toContain("Previous-generation sandboxes"); + expect(quiet).not.toContain("Preparation failed"); + }); + + it("never promotes an offline durable serving pin into ready target status", () => { + const pinned = { ...node("offline"), online: false, rollout: { state: "ready" as const, ready_generation: 4 } }; + const html = renderToStaticMarkup(); + expect(html).toContain("Target readiness unknown"); + expect(html).not.toContain("Ready for target"); + expect(renderToStaticMarkup()).toContain("Target readiness unknown"); + }); + + it("allows same-provider online editing with held resources but keeps reset active as a boundary", () => { + const render = (value: SandboxDeployment) => renderToStaticMarkup( true} onCancelReset={async () => true} onUpdate={async () => {}} />); + const html = render(deployment); + expect(html).toMatch(/]*class="button outline"[^>]*>Change resources<\/button>/); + expect(html).not.toMatch(/]*disabled[^>]*>Change resources<\/button>/); + expect(html).not.toContain("Add the nodes again after saving"); + const reset = { clear: "force" as const, requested_at: "2026-09-27T10:00:00Z", deadline_at: null, forced_at: "2026-09-27T10:00:00Z", remaining: { busy: 0, idle: 0, cleanup: 1, on_offline_nodes: 0, offline_nodes: [] } }; + expect(render({ ...deployment, reset })).not.toContain("Change resources"); + }); +}); diff --git a/apps/web/src/features/sandbox/SandboxRolloutSummary.tsx b/apps/web/src/features/sandbox/SandboxRolloutSummary.tsx new file mode 100644 index 000000000..05ddc872c --- /dev/null +++ b/apps/web/src/features/sandbox/SandboxRolloutSummary.tsx @@ -0,0 +1,34 @@ +import type { SandboxDeployment } from "@agents-core-web/agents-client"; +import { useId } from "react"; +import { useTranslation } from "react-i18next"; +import { HelpTip } from "../../components/console-ui"; +import { formatInteger } from "../../lib/format"; +import "./SandboxManagerView.css"; + +/** Core's projection is authoritative; node lists never establish rollout completion. */ +export function SandboxRolloutSummary({ deployment, stale = false, compact = false, onOpen }: { deployment: SandboxDeployment; stale?: boolean; compact?: boolean; onOpen?: () => void }) { + const { t, i18n } = useTranslation("sandbox"); + const id = useId(); + if (!deployment.provider || deployment.reset) return null; + const { rollout } = deployment; + const needsAttention = rollout.nodes && (rollout.nodes.failed > 0 || rollout.nodes.update_required > 0 || rollout.nodes.unknown > 0); + const count = (value: number) => formatInteger(value, i18n.resolvedLanguage); + return
+

{t("Configuration rollout")}

{t("Existing sandboxes keep their configuration generation. Saving does not move them or prove the target is ready.")} {rollout.state === "settled" ? t("Core reports no active preparation. This does not mean every node is ready.") : null}
+ {stale ?

{t("Rollout state is unconfirmed. These are the last confirmed observations.")}

: null} +

{t(rollout.state === "preparing" ? "Core is preparing the target configuration." : "No active preparation")}

+
+
{t("Target generation")}
{count(deployment.generation)}
+ {!compact || rollout.previous_generation_sandboxes > 0 ?
{t("Previous-generation sandboxes")}
{count(rollout.previous_generation_sandboxes)}
: null} +
+ {rollout.nodes && (!compact || rollout.state === "preparing" || needsAttention) ?
+
{t("Ready for target")}
{count(rollout.nodes.ready)}
+
{t("Preparing target")}
{count(rollout.nodes.preparing)}
+
{t("Preparation failed")}
{count(rollout.nodes.failed)}
+
{t("Node update required")}
{count(rollout.nodes.update_required)}
+
{t("Target readiness unknown")}
{count(rollout.nodes.unknown)}
+
: null} + {needsAttention ?

{t("Review affected nodes for preparation errors, required updates or an unconfirmed connection. Qualified earlier generations may still serve work.")}

: null} + {onOpen ? : null} +
; +} diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 8ab6f9a43..a3a315677 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -1,4 +1,4 @@ -import type { InitializeSandboxDeployment, SandboxProvider, SandboxResources, SandboxRuntimeRelease, SandboxSpecification } from "@agents-core-web/agents-client"; +import { AgentCoreError, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@agents-core-web/agents-client"; import { useQuery } from "@tanstack/react-query"; import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; @@ -15,6 +15,7 @@ import { installationQuery } from "../../lib/installation"; import type { MessageKey } from "../../lib/locale-strings"; import { sandboxConfigurationRejection } from "../../lib/sandbox-labels"; import { defaultSandboxResources, distributionRuntime, savedSpecification, validSandboxResources } from "./deployment-specification"; +import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update"; import { isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS } from "./runtime-release"; import "./sandbox-wizard.css"; @@ -70,23 +71,22 @@ function presetOf(provider: SandboxProvider, resources: SandboxResources): Prese * release comes from this console's distribution manifest when it serves one. * `current` pre-selects the saved choices when a deployment changes. Keeping * the backend keeps its saved size and Runtime; another backend starts from its - * defaults and this console's Runtime. An E2B key must be entered again. + * defaults and this console's Runtime. E2B updates can retain the saved key. * Docker isolates less than microsandbox, so choosing it takes a confirmation, * once per wizard session; a saved Docker deployment has already made it. * Core's address is config.json's `public_url`: the review only shows it, and * a configuration Core rejects for it is explained here, where it was saved. */ -export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disabled, editing = false, onSubmit }: { - /** The deployment's read-only Core address. */ +type WizardProps = { coreUrl: string; expectedGeneration: number; current?: { provider: SandboxProvider; specification?: SandboxSpecification; e2bTemplate?: string }; disabled: boolean; - /** PR-R edits only the saved provider; changing backend requires reset first. */ - editing?: boolean; - onSubmit: (input: InitializeSandboxDeployment) => Promise; -}) { - const { t } = useTranslation("sandbox"); +} & ({ editing: true; onSubmit: (input: UpdateSandboxDeployment) => Promise } | + { editing?: false; onSubmit: (input: InitializeSandboxDeployment) => Promise }); + +export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disabled, editing, onSubmit }: WizardProps) { + const { t, i18n } = useTranslation("sandbox"); const id = useId(); const [step, setStep] = useState(editing ? current?.provider === "e2b" ? "e2b" : "size" : "where"); const [where, setWhere] = useState(current ? (current.provider === "e2b" ? "direct" : "nodes") : null); @@ -95,6 +95,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const [resources, setResources] = useState(current?.specification?.resources ?? defaultSandboxResources("docker")); const [size, setSize] = useState(current?.specification ? presetOf(current.provider, current.specification.resources) ?? "current" : "standard"); const [apiKey, setApiKey] = useState(""); + const [replacementRequested, setReplacementRequested] = useState(false); const [template, setTemplate] = useState(current?.e2bTemplate ?? ""); const [runtime, setRuntime] = useState>({}); const [busy, setBusy] = useState(false); @@ -103,6 +104,8 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const keepMicrosandbox = useRef(null); // Core's reason for rejecting the saved configuration, such as E2B with a loopback public_url. const [rejection, setRejection] = useState(null); + const [resetRequired, setResetRequired] = useState(false); + const [addressRejected, setAddressRejected] = useState(false); const installation = useQuery(installationQuery); const address = coreUrl || installation.data?.public_url || null; const configuration = installation.data?.configuration ?? null; @@ -114,8 +117,9 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const needsRuntime = provider === "docker" || provider === "microsandbox"; const runtimeReady = !needsRuntime || isRuntimeRelease(release); - // Core keeps no key across a change: E2B always needs one. - const e2bReady = provider !== "e2b" || (apiKey.trim().length > 0 && validTemplate(template.trim())); + // Initial setup requires a key; an update may retain the committed key. + const keyReady = e2bKeyReady(Boolean(editing), replacementRequested, apiKey); + const e2bReady = provider !== "e2b" || (keyReady && validTemplate(template.trim())); // Core sizes E2B sandboxes from the template build, so E2B sends no resources. const sized = provider !== null && provider !== "e2b"; const sizeReady = provider !== null && (!sized || validSandboxResources(provider, resources)); @@ -149,18 +153,20 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab if (!ready || !provider) return; setBusy(true); setRejection(null); try { - await onSubmit({ - provider, - expected_generation: expectedGeneration, + const selection = { + provider, expected_generation: expectedGeneration, ...(sized ? { resources } : {}), ...(needsRuntime ? { runtime: release as SandboxRuntimeRelease } : {}), - ...(provider === "e2b" ? { e2b: { api_key: apiKey.trim(), template: template.trim() } } : {}), - }); + }; + if (editing) await onSubmit({ ...selection, ...(provider === "e2b" ? { e2b: e2bUpdateSelection(template, apiKey) } : {}) }); + else await onSubmit({ ...selection, ...(provider === "e2b" ? { e2b: { api_key: apiKey.trim(), template: template.trim() } } : {}) }); } catch (error) { // A configuration Core rejected is explained here; the page reports every other failure. - const reason = sandboxConfigurationRejection(error); + const reason = sandboxConfigurationRejection(error, i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"); if (reason === null) throw error; setRejection(reason); + setResetRequired(error instanceof AgentCoreError && ["e2b_team_mismatch", "sandbox_reset_required"].includes(error.code ?? "")); + setAddressRejected(error instanceof AgentCoreError && error.code === "sandbox_configuration_error"); } finally { setApiKey(""); setBusy(false); @@ -194,8 +200,8 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab page = (
- - setApiKey(event.target.value)} /> + + { setApiKey(event.target.value); setReplacementRequested(Boolean(event.target.value.trim())); }} /> setTemplate(event.target.value)} placeholder="oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b" autoComplete="off" spellCheck={false} aria-invalid={Boolean(template && !validTemplate(template.trim()))} /> @@ -210,7 +216,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const kept = saved && provider && presetOf(provider, saved.resources) === null ? saved.resources : null; const disks = (value: SandboxResources) => (provider === "microsandbox" ? diskLabel(value) : undefined); page = ( - +
{kept ? { setSize("current"); setResources(kept); setStep("review"); }} /> : null} {(Object.keys(options) as Preset[]).map((key) => ( @@ -237,9 +243,10 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
{t("Sandboxes run on")}
{where === "direct" ? t("E2B cloud") : `${t("Own machines")} · ${provider === "docker" ? "Docker" : "microsandbox"}`}
{t("Each sandbox")}
{sized ? sizeLabel(resources) : t("From the template build")}{provider === "microsandbox" ? {diskLabel(resources)} : null}
{provider === "e2b" ?
{t("Template build")}
{template || "—"}
: null} + {provider === "e2b" && editing ?
{t("E2B credential")}
{t(replacementRequested ? "Replace saved key" : "Keep saved key")}
: null} {needsRuntime ? (
-
{t("Runtime")}{t("The Runtime release every node runs: the saved one while the backend stays the same, otherwise the one this console distributes.")}
+
{t("Runtime")}{t("The target Runtime release. Existing sandboxes keep their owned release while nodes prepare the target.")}
{runtimeReady ? {release.source_commit!.slice(0, 12)} : {t("Runtime release needed")}{t("This console serves no Runtime manifest. Enter the release under advanced settings.")}}
) : null} @@ -255,7 +262,8 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab {rejection ? (

{rejection}

- {configuration ? ( + {resetRequired ?

{t("Cancel editing to use Reset deployment. Changing teams requires an explicit reset; the saved configuration is unchanged.")}

: null} + {addressRejected && configuration ? (
{t("Config file")}
{t("Then run")}
@@ -263,11 +271,12 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab ) : null}
) : null} - {/* Every save attempt clears the E2B key, so a second one needs it entered again. */} - {provider === "e2b" && !apiKey.trim() ? ( + {/* Initial setup needs the cleared key re-entered; updates may keep the saved key. */} + {provider === "e2b" && !keyReady ? (

{t("Enter the E2B key again to save.")} + {editing ? : null}

) : null} : null} + + ) : null} +
+ ); +} diff --git a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx index 97346fb07..f51e45852 100644 --- a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx +++ b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx @@ -1,8 +1,8 @@ import { AgentCoreError, type ExecutorCredential, type IssuedExecutorCredential } from "@agents-core-web/agents-client"; import { useQuery } from "@tanstack/react-query"; -import { Check, Copy, Download, Plus } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { Trans, useTranslation } from "react-i18next"; +import { Plus } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { ConfirmDialog } from "../../components/ConfirmDialog"; import { EmptyState, Section, StatusDot } from "../../components/console-ui"; @@ -14,10 +14,11 @@ import { failedLast, useFailureToast, useToast } from "../../components/Toast"; import { useDeleteFlow } from "../../lib/delete-flow"; import { formatDateTime, shortId } from "../../lib/format"; import { admin, useProjects } from "../../lib/projects"; -import { useCopy } from "../api-keys/IssuedKey"; -import { saveBlob } from "../skills/skill-operations"; -import { ExecutorInstallPanel, InstallCommand, useExecutorInstall, useSelectWhenCopyFails } from "./ExecutorInstallPanel"; -import { executorCredentialsQuery } from "./session-queries"; +import { ExecutorInstallPanel, useExecutorInstall } from "./ExecutorInstallPanel"; +import { CredentialFile } from "./executor-credential-file"; +import { executorConnectionQuery } from "./executor-connection-query"; +import { executorConnectionState } from "./executor-connection"; +import { ExecutorConnectionPanel } from "./ExecutorConnectionPanel"; /** An issuance that gets no answer in this time has an unknown outcome. */ const WRITE_TIMEOUT_MS = 30_000; @@ -32,17 +33,6 @@ function newKeyId(): string { return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } -/** - * The self-hosted executor's credential as one line of JSON. The installer - * also accepts it pretty-printed, but a one-line paste survives terminals that - * warn about or bracket multi-line pastes; `--credential-file` reads the same - * JSON from the downloaded file. - */ -function credentialText(credential: IssuedExecutorCredential): string { - const { key_id, environment_id, executor_token } = credential; - return JSON.stringify({ key_id, environment_id, executor_token }); -} - /** * A write either failed before Core acted ("rejected", with Core's reason) or * has an unknown outcome: no answer, a timeout, a 5xx or an unreadable reply. @@ -92,8 +82,10 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId const install = useExecutorInstall(environmentId, remoteUrl); // The one-time dialog repeats the command, so it is copied and run before the credential is pasted. const command = install.kind === "ready" ? install.command : null; - const query = useQuery(executorCredentialsQuery(projectId, sessionId, environmentId)); - const credentials = query.data ?? null; + const query = useQuery(executorConnectionQuery(projectId, sessionId, environmentId)); + const credentials = query.data?.data ?? null; + const connectionStale = failedLast(query) || query.isStale; + const connectionState = executorConnectionState(query.data, connectionStale); const { refetch } = query; const reread = () => { void refetch(); }; useFailureToast(credentials && failedLast(query) ? message(query.error) : null, t("executor.refreshFailed"), "executor-credentials-read"); @@ -140,7 +132,7 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId const findListed = async (keyId: string): Promise => { const result = await refetch(); if (!result.data || result.isError) return undefined; - return result.data.find((credential) => sameKey(credential.key_id, keyId)) ?? null; + return result.data.data.find((credential) => sameKey(credential.key_id, keyId)) ?? null; }; // An issued key ID whose secret never arrived is rotated for a fresh one, if it is still active. const recoverLost = (found: ExecutorCredential): boolean => { @@ -300,8 +292,9 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId
) : null} + {body} - + ); } - -/** - * The one-time credential: copied to paste at the installer's hidden prompt, - * or downloaded as a file for automation (`--credential-file`). What to do - * next comes first: in the dialog, run the install command shown with it; on - * the page, run the Connect a host command below; without the installer, save - * the credential. - */ -function CredentialFile({ credential, next, command = null }: { credential: IssuedExecutorCredential; next: "inline" | "panel" | "save"; command?: string | null }) { - const { t } = useTranslation("sessions"); - const text = credentialText(credential); - const { state, copy } = useCopy(text); - const file = useRef(null); - useSelectWhenCopyFails(state, file); - // A downloaded credential's object URL goes with the credential: on Done or when this leaves the page. - const downloads = useRef<(() => void)[]>([]); - useEffect(() => { - const revokes = downloads.current; - return () => { for (const revoke of revokes.splice(0)) revoke(); }; - }, []); - const download = () => { - downloads.current.push(saveBlob(new Blob([`${text}\n`], { type: "application/json" }), `executor-credential-${credential.environment_id.slice(0, 8)}.json`)); - }; - return ( -
-

{t("executor.issued.notice")}

-

{t(`executor.issued.next.${next}`)}

- {command ? : null} -
{text}
-
- - -
- {state === "failed" ?

{t("executor.issued.copyFailed")}

: null} -

- chmod 600 <file>, flag: --credential-file <absolute path> }} /> -

-
- ); -} diff --git a/apps/web/src/features/sessions/ExecutorInstallPanel.tsx b/apps/web/src/features/sessions/ExecutorInstallPanel.tsx index 0365f483b..344f63bcf 100644 --- a/apps/web/src/features/sessions/ExecutorInstallPanel.tsx +++ b/apps/web/src/features/sessions/ExecutorInstallPanel.tsx @@ -35,7 +35,7 @@ export function useExecutorInstall(environmentId: string, remoteUrl: string): Ex * for a credential from the section above at a hidden prompt. Shown only when * the console serves the self-hosted installer with a verified digest. */ -export function ExecutorInstallPanel({ install, archived }: { install: ExecutorInstallRead; archived: boolean }) { +export function ExecutorInstallPanel({ install, archived, connected = false }: { install: ExecutorInstallRead; archived: boolean; connected?: boolean }) { const { t } = useTranslation("sessions"); const { t: tCommon } = useTranslation("common"); const headingId = useId(); @@ -69,6 +69,10 @@ export function ExecutorInstallPanel({ install, archived }: { install: ExecutorI

{t("executor.install.title")}

{t("executor.install.lifecycle")} + + {connected ?
{body}
diff --git a/apps/web/src/features/sessions/executor-connection-query.ts b/apps/web/src/features/sessions/executor-connection-query.ts new file mode 100644 index 000000000..6441f6bda --- /dev/null +++ b/apps/web/src/features/sessions/executor-connection-query.ts @@ -0,0 +1,22 @@ +import type { ExecutorCredentialList } from "@agents-core-web/agents-client"; +import { queryOptions } from "@tanstack/react-query"; + +import { admin } from "../../lib/projects"; +import { sessionKey } from "./session-queries"; + +export const EXECUTOR_CONNECTION_POLL_MS = 5_000; + +/** Metadata and Core's connection observation share one read; secrets never enter this cache. */ +export function executorConnectionQuery(projectId: string, sessionId: string, environmentId: string) { + return queryOptions({ + queryKey: [...sessionKey(projectId, sessionId), "executor-connection", environmentId], + queryFn: async ({ signal }) => { + const { data, connection } = await admin.listExecutorCredentials(projectId, environmentId, { signal }); + return { connection, data: [...data].sort((a, b) => Number(a.revoked_at !== null) - Number(b.revoked_at !== null) || Date.parse(b.created_at) - Date.parse(a.created_at)) }; + }, + staleTime: EXECUTOR_CONNECTION_POLL_MS, + refetchInterval: EXECUTOR_CONNECTION_POLL_MS, + refetchIntervalInBackground: false, + retry: false, + }); +} diff --git a/apps/web/src/features/sessions/executor-connection.css b/apps/web/src/features/sessions/executor-connection.css new file mode 100644 index 000000000..647a1e488 --- /dev/null +++ b/apps/web/src/features/sessions/executor-connection.css @@ -0,0 +1,13 @@ +.executor-connection { padding: 16px; border-bottom: 1px solid var(--line); } +.executor-connection-heading { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.executor-connection-heading h3 { margin: 0; font-size: 13px; font-weight: 600; } +.executor-connection-heading .refresh-button { margin-left: auto; } +.executor-connection-facts { display: flex; gap: 24px; flex-wrap: wrap; margin: 12px 0 0; } +.executor-connection-facts > div { display: flex; align-items: center; gap: 8px; } +.executor-connection-facts dt { color: var(--ink-muted); } +.executor-connection-facts dd { margin: 0; } +.executor-connection-guidance { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 12px; } +.executor-connection-guidance p, .executor-connection-warning { margin: 0; color: var(--warning); } +.executor-connection-warning { margin-top: 10px; } +.executor-install-progress { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; color: var(--ink-muted); } +.executor-install-progress.is-done { color: var(--success); } diff --git a/apps/web/src/features/sessions/executor-connection.test.ts b/apps/web/src/features/sessions/executor-connection.test.ts new file mode 100644 index 000000000..cd21fe1d7 --- /dev/null +++ b/apps/web/src/features/sessions/executor-connection.test.ts @@ -0,0 +1,77 @@ +import type { ExecutorCredentialList } from "@agents-core-web/agents-client"; +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { boundExecutorCredential, executorConnectionState } from "./executor-connection"; +import { executorConnectionQuery, EXECUTOR_CONNECTION_POLL_MS } from "./executor-connection-query"; + +const list = vi.hoisted(() => vi.fn()); +vi.mock("../../lib/projects", () => ({ admin: { listExecutorCredentials: list } })); + +const active = { key_id: "bound-key", created_at: "2026-09-28T01:00:00Z", revoked_at: null }; +function read(status: ExecutorCredentialList["connection"]["status"] = "disconnected"): ExecutorCredentialList { + return { data: [active], connection: { status, bound_key_id: status === "never_enrolled" ? null : "BOUND-KEY", enrolled_at: status === "never_enrolled" ? null : "2026-09-28T01:00:00Z", last_seen_at: null } }; +} + +afterEach(() => vi.clearAllMocks()); + +describe("executor connection evidence", () => { + it("never treats an active credential or a recent heartbeat as a connection", () => { + expect(executorConnectionState(read("never_enrolled"), false)).toBe("never_enrolled"); + const disconnected = read(); + disconnected.connection.last_seen_at = new Date().toISOString(); + expect(executorConnectionState(disconnected, false)).toBe("disconnected"); + }); + it("accepts Core connectivity even without a recorded heartbeat", () => { + expect(executorConnectionState(read("connected"), false)).toBe("connected"); + }); + it("distinguishes bound-key revocation from unrelated revoked keys", () => { + const result = read(); + result.data.push({ ...active, key_id: "unrelated", revoked_at: "2026-09-28T02:00:00Z" }); + expect(executorConnectionState(result, false)).toBe("disconnected"); + result.data[0] = { ...active, revoked_at: "2026-09-28T02:00:00Z" }; + expect(boundExecutorCredential(result)?.key_id).toBe("bound-key"); + expect(executorConnectionState(result, false)).toBe("revoked"); + result.connection.status = "connected"; + expect(executorConnectionState(result, false)).toBe("connected"); + }); + it("does not guess a binding from credential order", () => { + const result = read(); + result.connection.bound_key_id = null; + expect(boundExecutorCredential(result)).toBeUndefined(); + expect(executorConnectionState(result, false)).toBe("disconnected"); + }); + it("withholds connected completion on stale or unread evidence", () => { + expect(executorConnectionState(read("connected"), true)).toBe("unknown"); + expect(executorConnectionState(undefined, false)).toBe("unknown"); + }); +}); + +describe("executor connection read", () => { + it("keeps the list and connection together, retains failed reads, and recovers without writes", async () => { + const client = new QueryClient(); + try { + const options = executorConnectionQuery("project", "session", "environment"); + const response = read("connected"); + response.data.unshift({ ...active, key_id: "old-revoked", revoked_at: "2026-09-28T02:00:00Z" }); + list.mockResolvedValueOnce(response); + const result = await client.fetchQuery(options); + expect(result.connection).toEqual(response.connection); + expect(result.data.map((credential) => credential.key_id)).toEqual(["bound-key", "old-revoked"]); + expect(response.data[0]?.key_id).toBe("old-revoked"); + list.mockRejectedValueOnce(new Error("unavailable")); + await expect(client.fetchQuery({ ...options, staleTime: 0 })).rejects.toThrow("unavailable"); + expect(client.getQueryData(options.queryKey)).toEqual(result); + expect(list).toHaveBeenCalledTimes(2); + list.mockResolvedValueOnce(read("disconnected")); + expect((await client.fetchQuery({ ...options, staleTime: 0 })).connection.status).toBe("disconnected"); + } finally { client.clear(); } + }); + it("bounds polling to five seconds and disables background polls and retries", () => { + const options = executorConnectionQuery("project", "session", "environment"); + expect(EXECUTOR_CONNECTION_POLL_MS).toBeGreaterThanOrEqual(5_000); + expect(options.refetchInterval).toBe(EXECUTOR_CONNECTION_POLL_MS); + expect(options.refetchIntervalInBackground).toBe(false); + expect(options.retry).toBe(false); + }); +}); diff --git a/apps/web/src/features/sessions/executor-connection.ts b/apps/web/src/features/sessions/executor-connection.ts new file mode 100644 index 000000000..85e309ee9 --- /dev/null +++ b/apps/web/src/features/sessions/executor-connection.ts @@ -0,0 +1,15 @@ +import type { ExecutorCredentialList } from "@agents-core-web/agents-client"; + +export type ExecutorConnectionState = "never_enrolled" | "connected" | "disconnected" | "revoked" | "unknown"; + +export function boundExecutorCredential(read: ExecutorCredentialList | undefined) { + const boundId = read?.connection.bound_key_id; + return boundId ? read.data.find((credential) => credential.key_id.toLowerCase() === boundId.toLowerCase()) : undefined; +} + +/** Core alone decides connectivity; a revoked matching row adds the recovery reason. */ +export function executorConnectionState(read: ExecutorCredentialList | undefined, stale: boolean): ExecutorConnectionState { + if (!read || stale) return "unknown"; + if (read.connection.status === "disconnected" && boundExecutorCredential(read)?.revoked_at) return "revoked"; + return read.connection.status; +} diff --git a/apps/web/src/features/sessions/executor-credential-file.tsx b/apps/web/src/features/sessions/executor-credential-file.tsx new file mode 100644 index 000000000..a686c1169 --- /dev/null +++ b/apps/web/src/features/sessions/executor-credential-file.tsx @@ -0,0 +1,64 @@ +import type { IssuedExecutorCredential } from "@agents-core-web/agents-client"; +import { Check, Copy, Download } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { Trans, useTranslation } from "react-i18next"; + +import { useCopy } from "../api-keys/IssuedKey"; +import { saveBlob } from "../skills/skill-operations"; +import { InstallCommand, useSelectWhenCopyFails } from "./ExecutorInstallPanel"; + +/** + * The self-hosted executor's credential as one line of JSON. The installer + * also accepts it pretty-printed, but a one-line paste survives terminals that + * warn about or bracket multi-line pastes; `--credential-file` reads the same + * JSON from the downloaded file. + */ +function credentialText(credential: IssuedExecutorCredential): string { + const { key_id, environment_id, executor_token } = credential; + return JSON.stringify({ key_id, environment_id, executor_token }); +} + +/** + * The one-time credential: copied to paste at the installer's hidden prompt, + * or downloaded as a file for automation (`--credential-file`). What to do + * next comes first: in the dialog, run the install command shown with it; on + * the page, run the Connect a host command below; without the installer, save + * the credential. + */ +export function CredentialFile({ credential, next, command = null }: { credential: IssuedExecutorCredential; next: "inline" | "panel" | "save"; command?: string | null }) { + const { t } = useTranslation("sessions"); + const text = credentialText(credential); + const { state, copy } = useCopy(text); + const file = useRef(null); + useSelectWhenCopyFails(state, file); + // A downloaded credential's object URL goes with the credential: on Done or when this leaves the page. + const downloads = useRef<(() => void)[]>([]); + useEffect(() => { + const revokes = downloads.current; + return () => { for (const revoke of revokes.splice(0)) revoke(); }; + }, []); + const download = () => { + downloads.current.push(saveBlob(new Blob([`${text}\n`], { type: "application/json" }), `executor-credential-${credential.environment_id.slice(0, 8)}.json`)); + }; + return ( +
+

{t("executor.issued.notice")}

+

{t(`executor.issued.next.${next}`)}

+ {command ? : null} +
{text}
+
+ + +
+ {state === "failed" ?

{t("executor.issued.copyFailed")}

: null} +

+ chmod 600 <file>, flag: --credential-file <absolute path> }} /> +

+
+ ); +} diff --git a/apps/web/src/i18n/locales/en/sessions.ts b/apps/web/src/i18n/locales/en/sessions.ts index 57325a59d..113e36758 100644 --- a/apps/web/src/i18n/locales/en/sessions.ts +++ b/apps/web/src/i18n/locales/en/sessions.ts @@ -148,6 +148,22 @@ export const sessions = { historyLoading: "Loading runtime history…", }, executor: { + connection: { + title: "Host connection", + help: "Core reports a connection only while the executor has current authority and a live connection. Last seen is the last recorded heartbeat and may lag. This does not confirm model or harness readiness.", + status: { never_enrolled: "Never connected", connected: "Connected", disconnected: "Disconnected", revoked: "Bound credential revoked", unknown: "Unknown" }, + boundKey: "Bound credential", + lastSeen: "Last seen", + notBound: "Not bound", + noHeartbeat: "No heartbeat recorded", + stale: "Refresh failed. Showing the last loaded binding; the current connection is unknown.", + refreshing: "Refreshing the last loaded binding; the current connection is not yet confirmed.", + failed: "The connection could not be read. Refresh to try again.", + bound: "This Environment is bound to credential {{id}}. Issuing a new credential will not replace it. Rotate the bound credential to reconnect.", + restore: "Bound credential {{id}} is revoked. Rotate it to restore, then rerun the host installer. Issuing a new credential will not replace the binding.", + rotateRestore: "Rotate to restore", + rotateBound: "Rotate bound credential", + }, title: "Executor credentials", help: "A credential lets one self-hosted executor connect to Core for this environment only. It cannot call the Agents API. Paste it into the installer on the executor host and keep it private. Core shows each credential once; rotate a credential to replace a lost one.", issue: "Issue credential", @@ -189,6 +205,8 @@ export const sessions = { done: "Done", }, install: { + hostDone: "Run on host · Done", + hostPending: "Run on host · Awaiting connection", title: "Connect a host", lifecycle: "After the credential is revoked or rotated, the host's executor disconnects with a single message and won't retry; its container keeps running. To reconnect, rotate that same credential, rerun the same command and paste the rotated credential; issuing a new credential doesn't reconnect an Environment that has already connected. To remove the Runtime, stop its container.", steps: "Run this command on the host. At its hidden prompt, paste a credential from this section: it's shown once, after Issue credential or Rotate. The same command is safe to rerun.", diff --git a/apps/web/src/i18n/locales/zh-CN/sessions.ts b/apps/web/src/i18n/locales/zh-CN/sessions.ts index ab7d2dbb8..d14fd6a1c 100644 --- a/apps/web/src/i18n/locales/zh-CN/sessions.ts +++ b/apps/web/src/i18n/locales/zh-CN/sessions.ts @@ -145,6 +145,22 @@ export const sessions = { historyLoading: "正在加载运行时历史…", }, executor: { + connection: { + title: "主机连接", + help: "只有执行器仍具备有效权限且连接存活时,Core 才报告已连接。最后在线时间来自最近一次心跳,可能有延迟;它不代表模型或运行引擎已就绪。", + status: { never_enrolled: "尚未连接", connected: "已连接", disconnected: "已断开", revoked: "绑定凭证已撤销", unknown: "未知" }, + boundKey: "绑定凭证", + lastSeen: "最后在线", + notBound: "尚未绑定", + noHeartbeat: "尚无心跳记录", + stale: "刷新失败。当前显示上次读取的绑定信息,连接状态未知。", + refreshing: "正在刷新上次读取的绑定信息,尚未确认当前连接。", + failed: "无法读取连接状态,请刷新重试。", + bound: "此 Environment 已绑定凭证 {{id}},签发新凭证不会替换绑定。请轮换绑定凭证以重新连接。", + restore: "绑定凭证 {{id}} 已撤销。请轮换以恢复,再在主机上重跑安装命令;签发新凭证不会替换绑定。", + rotateRestore: "轮换以恢复", + rotateBound: "轮换绑定凭证", + }, title: "Executor 凭据", help: "凭据让一个自托管 executor 连接 Core,只对这个环境有效,不能调用 Agents API。请在 executor 所在的主机上把它粘贴给安装程序,并妥善保管。每份凭据只显示一次;丢失时,轮换凭据即可换一份新的。", issue: "签发凭据", @@ -186,6 +202,8 @@ export const sessions = { done: "完成", }, install: { + hostDone: "在主机上运行 · 已完成", + hostPending: "在主机上运行 · 等待连接", title: "连接主机", lifecycle: "凭据被撤销或轮换后,主机上的 executor 会断开连接,只给出一条提示且不再重试;它的容器会继续运行。要重新连接,请轮换同一份凭据,重新运行同一条命令并粘贴轮换后的凭据;签发一份新凭据无法让已经连接过的环境重新连接。要移除 Runtime,停止它的容器即可。", steps: "在主机上运行这条命令,并在隐藏的输入提示处粘贴本区域的凭据:凭据只在签发或轮换后显示一次。同一条命令可以放心重复运行。", From 770c9de39c1a4e2cb92d0848ea450e19dfe16ea0 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 09:28:54 +0000 Subject: [PATCH 07/16] Localize typed Core errors and attach rejected form fields --- .../features/api-keys/KeyFlowDialogs.test.tsx | 15 ++++- .../src/features/api-keys/KeyFlowDialogs.tsx | 19 +++++-- .../src/features/api-keys/ProjectsPage.tsx | 3 +- apps/web/src/features/api-keys/key-flows.ts | 4 +- .../src/features/sandbox/NodeEditDialog.tsx | 23 +++++--- .../src/features/sandbox/NodeEnrollment.tsx | 12 ++-- .../features/sandbox/SandboxSetupWizard.tsx | 42 ++++++++------ .../features/system/DefaultModelsSection.tsx | 32 +++++++---- apps/web/src/i18n/locales/en/common.ts | 3 + apps/web/src/i18n/locales/en/core-errors.ts | 43 ++++++++++++++ apps/web/src/i18n/locales/zh-CN/common.ts | 3 + .../web/src/i18n/locales/zh-CN/core-errors.ts | 43 ++++++++++++++ apps/web/src/lib/core-error.test.ts | 57 +++++++++++++++++++ apps/web/src/lib/core-error.ts | 49 ++++++++++++++++ apps/web/src/lib/read-error.ts | 6 +- apps/web/src/lib/sandbox-labels.ts | 38 +++---------- 16 files changed, 309 insertions(+), 83 deletions(-) create mode 100644 apps/web/src/i18n/locales/en/core-errors.ts create mode 100644 apps/web/src/i18n/locales/zh-CN/core-errors.ts create mode 100644 apps/web/src/lib/core-error.test.ts create mode 100644 apps/web/src/lib/core-error.ts diff --git a/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx b/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx index c9dcf003e..e68dd3e1c 100644 --- a/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx +++ b/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx @@ -3,7 +3,8 @@ import type { ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import type { KeyFlow } from "./key-flows"; +import { AgentCoreError } from "@agents-core-web/agents-client"; +import { flowError, keyFlowReducer, type KeyFlow } from "./key-flows"; import { KeyFlowDialogs, PendingKeyNotice } from "./KeyFlowDialogs"; import type { KeyFlowControls } from "./use-key-flow"; import { type AdminIssuedKey, type Project } from "../../lib/admin-view"; @@ -41,3 +42,15 @@ describe("one-time key display", () => { expect(html).toMatch(/ : null} - ; + return <> +
+

{t("Configuration rollout")}

+ + +
+ setOpen(false)} footer={onOpen ? : undefined}> +
+
+ + + {t(stale ? "Rollout state is unconfirmed. These are the last confirmed observations." : rollout.state === "settled" ? "Core reports no active preparation. This does not mean every node is ready." : "Core is preparing the target configuration.")} + {needsAttention || unknown ? ` ${t("Review affected nodes for preparation errors, required updates or an unconfirmed connection. Qualified earlier generations may still serve work.")}` : null} + +
+
+
{t("Core preparation")}
{t(rollout.state === "preparing" ? "Preparing configuration" : "No active preparation")}
+
{t("Target generation")}{t("Existing sandboxes keep their configuration generation. Saving does not move them or prove the target is ready.")}
{count(deployment.generation)}
+
{t("Previous-generation sandboxes")}
{count(rollout.previous_generation_sandboxes)}
+ {rollout.nodes ? <> +
{t("Ready for target")}
{count(rollout.nodes.ready)}
+
{t("Preparing target")}
{count(rollout.nodes.preparing)}
+
{t("Preparation failed")}
{count(rollout.nodes.failed)}
+
{t("Node update required")}
{count(rollout.nodes.update_required)}
+
{t("Target readiness unknown")}
{count(rollout.nodes.unknown)}
+ : null} +
+
+
+ ; } diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 18ccc1bb2..f6e87b7d0 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -4,6 +4,10 @@ export const chinese = { "No active preparation": "没有正在进行的准备任务", "Review nodes": "检查节点", "Configuration rollout": "配置更新进度", + "View rollout details": "查看详情", + "Preparing configuration": "正在准备配置", + "Rollout state unconfirmed": "配置更新状态未确认", + "Core preparation": "Core 准备状态", "Target generation": "目标代次", "Previous-generation sandboxes": "旧代次沙箱", "Ready for target": "目标配置已就绪", From 45cb56aa1ea6354d6d158417fd8dead38caad388 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 09:41:00 +0000 Subject: [PATCH 09/16] Keep executor binding guidance behind its help tip --- .../sessions/ExecutorConnectionPanel.tsx | 17 ++++++++++------- .../features/sessions/executor-connection.css | 6 ++---- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/web/src/features/sessions/ExecutorConnectionPanel.tsx b/apps/web/src/features/sessions/ExecutorConnectionPanel.tsx index 76b25a7c2..bc1afc4f2 100644 --- a/apps/web/src/features/sessions/ExecutorConnectionPanel.tsx +++ b/apps/web/src/features/sessions/ExecutorConnectionPanel.tsx @@ -34,15 +34,18 @@ export function ExecutorConnectionPanel({ read, stale, failed, refreshing, archi {stale && read ?

{t(failed ? "executor.connection.stale" : "executor.connection.refreshing")}

: null} {!read && failed ?

{t("executor.connection.failed")}

: null}
-
{t("executor.connection.boundKey")}
{connection?.bound_key_id ? : t(connection?.status === "never_enrolled" ? "executor.connection.notBound" : "executor.connection.status.unknown")}
+
+
{t("executor.connection.boundKey")}
+
+ {connection?.bound_key_id ? <> + + {bound && !archived ? : null} + {t(bound?.revoked_at ? "executor.connection.restore" : "executor.connection.bound", { id: shortId(connection.bound_key_id) })} + : t(connection?.status === "never_enrolled" ? "executor.connection.notBound" : "executor.connection.status.unknown")} +
+
{t("executor.connection.lastSeen")}
{connection?.last_seen_at ? formatDateTime(Date.parse(connection.last_seen_at) / 1000, i18n.resolvedLanguage) : t(connection ? "executor.connection.noHeartbeat" : "executor.connection.status.unknown")}
- {connection?.bound_key_id ? ( -
-

{t(bound?.revoked_at ? "executor.connection.restore" : "executor.connection.bound", { id: shortId(connection.bound_key_id) })}

- {bound && !archived ? : null} -
- ) : null} ); } diff --git a/apps/web/src/features/sessions/executor-connection.css b/apps/web/src/features/sessions/executor-connection.css index 647a1e488..80cc80e6b 100644 --- a/apps/web/src/features/sessions/executor-connection.css +++ b/apps/web/src/features/sessions/executor-connection.css @@ -5,9 +5,7 @@ .executor-connection-facts { display: flex; gap: 24px; flex-wrap: wrap; margin: 12px 0 0; } .executor-connection-facts > div { display: flex; align-items: center; gap: 8px; } .executor-connection-facts dt { color: var(--ink-muted); } -.executor-connection-facts dd { margin: 0; } -.executor-connection-guidance { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 12px; } -.executor-connection-guidance p, .executor-connection-warning { margin: 0; color: var(--warning); } -.executor-connection-warning { margin-top: 10px; } +.executor-connection-facts dd { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0; } +.executor-connection-warning { margin: 10px 0 0; color: var(--warning); } .executor-install-progress { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; color: var(--ink-muted); } .executor-install-progress.is-done { color: var(--success); } From 6ca053b93d923f18ff08529aaa4aa8aea34bfb75 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 09:49:29 +0000 Subject: [PATCH 10/16] Move sandbox configuration into a System secondary page --- apps/web/src/ConsoleApp.tsx | 6 +- .../sandbox/SandboxDeploymentPage.tsx | 115 ++++++++++++++++++ .../sandbox/SandboxDeploymentSettings.tsx | 38 +++--- .../features/sandbox/SandboxManagerView.tsx | 112 +++-------------- .../features/sandbox/SandboxPageAccess.tsx | 15 +++ .../sandbox/sandbox-page-ownership.test.tsx | 78 ++++++++++++ .../sandbox/use-sandbox-page-state.ts | 32 +++++ apps/web/src/features/system/SystemPage.tsx | 94 ++------------ .../src/i18n/locales/en/sandbox-navigation.ts | 11 ++ .../i18n/locales/zh-CN/sandbox-navigation.ts | 11 ++ 10 files changed, 316 insertions(+), 196 deletions(-) create mode 100644 apps/web/src/features/sandbox/SandboxDeploymentPage.tsx create mode 100644 apps/web/src/features/sandbox/SandboxPageAccess.tsx create mode 100644 apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx create mode 100644 apps/web/src/features/sandbox/use-sandbox-page-state.ts create mode 100644 apps/web/src/i18n/locales/en/sandbox-navigation.ts create mode 100644 apps/web/src/i18n/locales/zh-CN/sandbox-navigation.ts 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/features/sandbox/SandboxDeploymentPage.tsx b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx new file mode 100644 index 000000000..3e44cb9d9 --- /dev/null +++ b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import type { InitializeSandboxDeployment, UpdateSandboxDeployment, SandboxDeployment, StartSandboxReset } from "@agents-core-web/agents-client"; +import { useQueryClient } from "@tanstack/react-query"; +import { ArrowLeft } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { TableSkeleton } from "../../components/Skeleton"; +import { HelpTip, RefreshButton } from "../../components/console-ui"; +import { ErrorDialog } from "../../components/ErrorDialog"; +import { ErrorState } from "../../components/ErrorState"; +import { InstallationNotice } from "../../components/InstallationNotice"; +import { useFailureToast, useToast } from "../../components/Toast"; +import { useConsoleNavigation } from "../../lib/console-navigation"; +import { sandboxConfigurationRejection, sandboxRequestError, sandboxWriteUncertain } from "../../lib/sandbox-labels"; +import { sandboxAdmin } from "./sandbox-queries"; +import { SandboxPageAccess } from "./SandboxPageAccess"; +import { useSandboxPageState } from "./use-sandbox-page-state"; +import { sandboxWriteOwnershipQuery } from "./sandbox-write-ownership"; +import { writeSandboxDeployment } from "./sandbox-deployment-write"; +import { SandboxSetupWizard } from "./SandboxSetupWizard"; +import { SandboxDeploymentSettings } from "./SandboxDeploymentSettings"; +import "./SandboxManagerView.css"; + +function DeploymentHeader({ actions }: { actions?: ReactNode }) { + const { t } = useTranslation("sandboxNavigation"); + const { navigate } = useConsoleNavigation(); + return
+
+ +

{t("title")}

{t("help")} +
+ {actions ?
{actions}
: null} +
; +} + +/** System's secondary page is the sole owner of deployment configuration controls. */ +export function SandboxDeploymentPage() { + const { i18n } = useTranslation("sandbox"); + return
+ }>{() => } +
; +} + +function DeploymentConfiguration() { + const { t, i18n } = useTranslation("sandbox"); + const { t: tNavigation } = useTranslation("sandboxNavigation"); + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const { navigate } = useConsoleNavigation(); + const queryClient = useQueryClient(); + const { deploymentQuery, snapshot, installation, loading, busy, setupNeedsRefresh, confirmed, fresh, refresh, configurationKey } = useSandboxPageState(); + const error: unknown = deploymentQuery.error; + const toast = useToast(); + const [writeFailure, setWriteFailure] = useState<{ error: unknown; open: boolean; editing: boolean } | null>(null); + const lifetime = useRef(null); + useEffect(() => { + const controller = new AbortController(); lifetime.current = controller; + return () => { controller.abort(); lifetime.current = null; }; + }, []); + const refreshByUser = () => { + void refresh().then((result) => { + if (!result.isError && queryClient.getQueryData(sandboxWriteOwnershipQuery.queryKey)?.phase === "idle") setWriteFailure(null); + if (result.isError && result.data) toast.show(t("Refresh failed; showing the last loaded state."), { tone: "error", detail: sandboxRequestError(result.error, locale), key: "sandbox-read" }); + }); + }; + useFailureToast(snapshot && deploymentQuery.isError ? sandboxRequestError(deploymentQuery.error, locale) : null, t("Refresh failed; showing the last loaded state."), "sandbox-read"); + + // The shared mutation owner outlives this page. Leaving only suppresses local UI effects. + async function changeDeployment(operation: (signal: AbortSignal) => Promise, fromWizard = false, editing = false): Promise { + const controller = lifetime.current; + if (!controller || busy || loading || setupNeedsRefresh || !fresh) return false; + setWriteFailure(null); + try { + const deployment = await writeSandboxDeployment(queryClient, operation); + return deployment !== null && !controller.signal.aborted; + } catch (error) { + if (!controller.signal.aborted) { + if (fromWizard && sandboxConfigurationRejection(error) !== null) throw error; + if (sandboxWriteUncertain(error)) setWriteFailure({ error, open: true, editing }); + else toast.show(t("Core rejected the sandbox change"), { tone: "error", detail: sandboxRequestError(error, locale), key: "sandbox-write" }); + } + } + return false; + } + async function initialize(input: InitializeSandboxDeployment) { + if (await changeDeployment((signal) => sandboxAdmin.initializeDeployment(input, { signal }), true) && input.provider !== "e2b" && !installation.data?.local_only) navigate("nodes", {}, "add-node"); + } + async function update(input: UpdateSandboxDeployment) { + return changeDeployment((signal) => sandboxAdmin.updateDeployment({ ...input, expected_generation: snapshot!.deployment.generation }, { signal }), true, true); + } + const startReset = (input: StartSandboxReset) => changeDeployment((signal) => sandboxAdmin.startReset(input, { signal })); + const cancelReset = (expectedGeneration: number) => changeDeployment((signal) => sandboxAdmin.cancelReset(expectedGeneration, { signal })); + const unknownWrite = writeFailure?.open ?
+

{sandboxRequestError(writeFailure.error, locale)}

+

{t("Refresh sandbox state to confirm whether the change was saved before submitting again.")}

+
: null; + + return <> + + {snapshot?.deployment.provider && snapshot.deployment.provider !== "e2b" ? : null} + + } /> +
+ + {!snapshot && (loading || deploymentQuery.isPending) ? : null} + {snapshot && deploymentQuery.isError ?

{t("Refresh failed; showing the last loaded state.")}

: null} + {setupNeedsRefresh ?

{t("Refresh sandbox state to confirm whether the change was saved before submitting again.")}

: null} + {busy ? {t("Saving sandbox change…")} : null} + {!snapshot && error !== null ? : null} + {snapshot && !snapshot.deployment.provider && !snapshot.deployment.reset ? : null} + {snapshot?.deployment.provider || snapshot?.deployment.reset ? : null} +
+ setWriteFailure((failure) => failure && { ...failure, open: false })}> + {unknownWrite} + + ; +} diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx index c753db09d..e13577c7a 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx @@ -1,7 +1,8 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import type { UpdateSandboxDeployment, SandboxDeployment, StartSandboxReset } from "@agents-core-web/agents-client"; import { useTranslation } from "react-i18next"; -import { HelpTip } from "../../components/console-ui"; +import { Modal } from "../../components/Modal"; +import { HelpTip, RefreshButton } from "../../components/console-ui"; import { formatBytes, formatPeriod, MISSING } from "../../lib/format"; import type { MessageKey } from "../../lib/locale-strings"; import { sandboxProviderLabel } from "../../lib/sandbox-labels"; @@ -13,15 +14,20 @@ import { SandboxSetupWizard } from "./SandboxSetupWizard"; const MIB = 2 ** 20; const buildStatusLabel: Record, MessageKey> = { ready: "Ready", notReady: "Not ready", unknown: "Unknown state" }; -export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset, onCancelReset, onUpdate }: { +export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset, onCancelReset, onUpdate, writeFailure, onRefresh, refreshing, pending }: { deployment: SandboxDeployment; disabled: boolean; fresh: boolean; onReset: (input: StartSandboxReset) => Promise; onCancelReset: (expectedGeneration: number) => Promise; - onUpdate: (input: UpdateSandboxDeployment) => Promise; + onUpdate: (input: UpdateSandboxDeployment) => Promise; + writeFailure?: ReactNode; + onRefresh: () => void; + refreshing: boolean; + pending: boolean; }) { const { t, i18n } = useTranslation("sandbox"); + const { t: tNavigation } = useTranslation("sandboxNavigation"); const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; const [changing, setChanging] = useState(false); const spec = deployment.specification; @@ -34,24 +40,25 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset return

{t("Deployment provider")}

- + {t("One provider serves this deployment. Reset it before choosing a different backend.")} {deployment.provider === "e2b" ? ` ${t("Core creates E2B sandboxes directly. No node enrollment is needed.")} ${t("Saved configuration does not confirm execution readiness. Session and Environment state report actual execution.")}` : ""} +
+ {spec?.runtime ?
{t("Runtime")}
{spec.runtime.source_commit.slice(0, 12)}
: null} + {deployment.suspension ?
{t("Idle suspension")}
{t("After {{idle}} · kept {{retention}}", { idle: formatPeriod(deployment.suspension.idle_seconds, i18n.resolvedLanguage), retention: formatPeriod(deployment.suspension.retention_seconds, i18n.resolvedLanguage) })}
: null} +
{t("Allocated resources")}
{deployment.resources?.allocations ?? t("Unknown state")}
+
{t("Pending environments")}
{deployment.resources?.pending ?? t("Unknown state")}
+
{t("Provider")}
{sandboxProviderLabel(deployment.provider, locale)}
{t("Each sandbox")}
{size ? `${sizeLabel(size)}${size.root_disk_mib ? ` · ${t("Root disk {{root}} · data disk {{data}}", { root: formatBytes(size.root_disk_mib * MIB), data: formatBytes((size.environment_disk_mib ?? 0) * MIB) })}` : ""}` : MISSING}
- {spec?.runtime ?
{t("Runtime")}
{spec.runtime.source_commit.slice(0, 12)}
: null} - {deployment.suspension ?
{t("Idle suspension")}
{t("After {{idle}} · kept {{retention}}", { idle: formatPeriod(deployment.suspension.idle_seconds, i18n.resolvedLanguage), retention: formatPeriod(deployment.suspension.retention_seconds, i18n.resolvedLanguage) })}
: null} -
{t("Allocated resources")}
{deployment.resources?.allocations ?? t("Unknown state")}
-
{t("Pending environments")}
{deployment.resources?.pending ?? t("Unknown state")}
{deployment.provider === "e2b" ?
-
{t("Immutable Runtime template")}
{deployment.e2b?.template || t("Unknown state")}
-
{t("Template build")}
+
{t("Template build")}{deployment.e2b?.template || t("Unknown state")}
{[ t(buildStatusLabel[templateBuildStatus(build)]), ...(buildSize ? [sizeLabel(buildSize)] : []), @@ -68,18 +75,17 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset {t("Changes keep this backend and existing Sessions and nodes. Core prepares the new target for future work; placement may continue on qualified earlier generations.")}
- {changing ? <> + { if (!pending) setChanging(false); }} footer={<>}> { if (await onUpdate(input)) setChanging(false); }} /> -

{t("Existing sandboxes keep their configuration generation. Saving does not move them or prove the target is ready.")}

- - : null} + {writeFailure} +
: null}
; diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 720873d4e..003414a27 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -1,23 +1,20 @@ import { useCallback, useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; -import { type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxDeployment, type SandboxNode, type StartSandboxReset } from "@agents-core-web/agents-client"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { type SandboxNode } from "@agents-core-web/agents-client"; +import { useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Pencil, Plus, Server, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { ConfirmDialog } from "../../components/ConfirmDialog"; import { EmptyState, HelpTip, RefreshButton } from "../../components/console-ui"; -import { ErrorDialog } from "../../components/ErrorDialog"; import { ErrorState } from "../../components/ErrorState"; import { useFailureToast, useToast } from "../../components/Toast"; import { useConsoleIntent, useConsoleNavigation } from "../../lib/console-navigation"; import { InstallationNotice } from "../../components/InstallationNotice"; import { installationQuery } from "../../lib/installation"; -import { sandboxConfigurationRejection, sandboxRequestError, sandboxWriteUncertain } from "../../lib/sandbox-labels"; +import { sandboxRequestError } from "../../lib/sandbox-labels"; import type { SandboxConsoleConfig } from "./console-config"; -import { sandboxAdmin, sandboxConsoleConfigQuery } from "./sandbox-queries"; -import { useSandboxManagerState } from "./use-sandbox-manager-state"; -import { writeSandboxDeployment } from "./sandbox-deployment-write"; -import { SandboxSetupWizard } from "./SandboxSetupWizard"; -import { SandboxDeploymentSettings } from "./SandboxDeploymentSettings"; +import { sandboxAdmin } from "./sandbox-queries"; +import { useSandboxPageState } from "./use-sandbox-page-state"; +import { SandboxPageAccess } from "./SandboxPageAccess"; import { NodeEnrollment } from "./NodeEnrollment"; import { NodeList, onOldAddress } from "./NodeList"; import { NodeDetail } from "./NodeDetail"; @@ -26,52 +23,40 @@ import { NodeCleanupDialog, type NodeCleanup } from "./NodeCleanupDialog"; import { sandboxSize } from "./deployment-specification"; import "./SandboxManagerView.css"; -/** Nodes: the deployment provider, the node list and one node's detail (`#nodes?id=…`). */ +/** Nodes owns node enrollment, the list and individual node management. */ export function SandboxManagerView() { const { i18n } = useTranslation("sandbox"); const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; return
- + }>{(config) => }
; } -/** The page header; an E2B deployment has no machines, so the page is its sandbox backend. */ -function NodesPageHeader({ title, count, back, actions, cloud = false, headingRef }: { title?: ReactNode; count?: number; back?: () => void; actions?: ReactNode; cloud?: boolean; headingRef?: RefObject }) { +/** Node list and node detail share one header. */ +function NodesPageHeader({ title, count, back, actions, headingRef }: { title?: ReactNode; count?: number; back?: () => void; actions?: ReactNode; headingRef?: RefObject }) { const { t } = useTranslation("sandbox"); return
{back ? : null} -

{title ?? t(cloud ? "Sandbox backend" : "Nodes")}

+

{title ?? t("Nodes")}

{count === undefined ? null : {count}} - {back ? null : {t(cloud ? "E2B runs this deployment's sandboxes in its cloud. There are no machines to add." : "Your hosts for running sandboxes.")}} + {back ? null : {t("Your hosts for running sandboxes.")}}
{actions ?
{actions}
: null}
; } -function SandboxAccess() { - const { t } = useTranslation("sandbox"); - const { data: config, isPending: checking, isFetching, isError, refetch } = useQuery(sandboxConsoleConfigQuery); - if (isError && config === undefined) return <>

{t("The console configuration could not be read. Refresh to try again.")}

; - if (checking) return <>

{t("Connecting to this console's Core…")}

; - if (!config?.sandbox_admin) return <>

{t("Sandbox administration is not configured on this console.")}

; - return ; -} - function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig }) { const { t, i18n } = useTranslation("sandbox"); const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; const { params, navigate, back: goBack } = useConsoleNavigation(); const client = sandboxAdmin; const queryClient = useQueryClient(); - const { ownership, deploymentQuery, query, deployment, compatible, inventoryOlder, inventoryLoading, snapshot } = useSandboxManagerState(); - const { refetch: refetchSnapshot } = query; - const installation = useQuery(installationQuery); + const { deploymentQuery, query, compatible, inventoryOlder, inventoryLoading, snapshot, installation, loading, busy, setupNeedsRefresh, confirmed, fresh, refetch, refresh } = useSandboxPageState(); + const { t: tSandboxNav } = useTranslation("sandboxNavigation"); const localOnly = installation.data?.local_only === true; const { t: tCommon } = useTranslation("common"); - const loading = deploymentQuery.isFetching; const error: unknown = deploymentQuery.error; - const busy = ownership.data.phase === "pending"; const [removeTarget, setRemoveTarget] = useState(null); const [editTarget, setEditTarget] = useState(null); // The Add node dialog; it stays mounted with the page so its command survives closing. @@ -80,26 +65,6 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const [removeError, setRemoveError] = useState(null); // After a removal, the host's uninstall command; it stays for the closing animation. const [cleanup, setCleanup] = useState<{ node: NodeCleanup; open: boolean } | null>(null); - // QueryClient owns this across route lifetimes. Only a successful authoritative - // read begun after settlement releases uncertain/pending mutation ownership. - const setupNeedsRefresh = ownership.data.phase === "reconcile"; - const confirmed = deployment !== undefined && !deploymentQuery.isError && ownership.data.phase === "idle"; - // Writes additionally wait for any read in flight. - const fresh = confirmed && !loading; - // A write with an uncertain outcome opens a dialog with the reason; the error stays for the closing animation. - const [writeFailure, setWriteFailure] = useState<{ error: unknown; open: boolean } | null>(null); - const { refetch: refetchDeployment } = deploymentQuery; - const refetch = useCallback(async () => { - const result = await refetchDeployment(); - if (!result.isError) await refetchSnapshot(); - return result; - }, [refetchDeployment, refetchSnapshot]); - const refresh = useCallback(() => { - // Retry observations without discarding a draft for the same Core lifecycle. - // The wizard reads the address and config file from the installation. - void queryClient.invalidateQueries({ queryKey: installationQuery.queryKey }); - return refetch(); - }, [queryClient, refetch]); // The enrollment dialog's reads: the node list alone, every few seconds while it waits. It settles // when the read does, which the dialog waits for before calling a command expired. const refreshNodes = useCallback(() => refetch(), [refetch]); @@ -109,8 +74,6 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig // A refresh the administrator asks for reports its failure even while an earlier one is still unconfirmed; // the enrollment dialog's own repeated refreshes do not. const refreshByUser = () => { - // Reconciliation keeps an applicable draft. The authoritative lifecycle key - // below discards it only when Core confirms a different configuration. void queryClient.invalidateQueries({ queryKey: installationQuery.queryKey }); void refetch().then((result) => { if (result.isError && result.data) toast.show(t("Refresh failed; showing the last loaded state."), { tone: "error", detail: sandboxRequestError(result.error, locale), key: "sandbox-read" }); @@ -121,41 +84,6 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const controller = new AbortController(); lifetime.current = controller; return () => { controller.abort(); lifetime.current = null; }; }, []); - /** Whether Core confirmed the change. `fromWizard`: a configuration Core rejects is thrown back to the wizard that sent it. */ - async function changeDeployment(operation: (signal: AbortSignal) => Promise, fromWizard = false): Promise { - const controller = lifetime.current; - if (!controller || busy || loading || setupNeedsRefresh || !fresh) return false; - setWriteFailure(null); - try { - const deployment = await writeSandboxDeployment(queryClient, operation); - return deployment !== null && !controller.signal.aborted; - } catch (error) { - if (!controller.signal.aborted) { - if (fromWizard && sandboxConfigurationRejection(error) !== null) throw error; - if (sandboxWriteUncertain(error)) setWriteFailure({ error, open: true }); - else toast.show(t("Core rejected the sandbox change"), { tone: "error", detail: sandboxRequestError(error, locale), key: "sandbox-write" }); - } - } - return false; - } - /** First setup; own machines continue straight to adding the first node. */ - async function initialize(input: InitializeSandboxDeployment) { - if (await changeDeployment((signal) => client.initializeDeployment(input, { signal }), true) && input.provider !== "e2b" && !localOnly) setAdding(true); - } - async function update(input: UpdateSandboxDeployment) { - await changeDeployment((signal) => client.updateDeployment({ ...input, expected_generation: snapshot!.deployment.generation }, { signal }), true); - } - const startReset = (input: StartSandboxReset) => changeDeployment((signal) => client.startReset(input, { signal })); - const cancelReset = (expectedGeneration: number) => changeDeployment((signal) => client.cancelReset(expectedGeneration, { signal })); - const writeDialog = setWriteFailure((failure) => failure && { ...failure, open: false })} - > -

{writeFailure ? sandboxRequestError(writeFailure.error, locale) : null}

-

{t("Refresh sandbox state to confirm whether the change was saved before submitting again.")}

-
; const askRemove = (node: SandboxNode) => { setRemoveError(null); setRemoveTarget(node); }; async function remove() { const controller = lifetime.current; @@ -188,9 +116,6 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const nodes = snapshot?.nodes ?? []; const allocations = snapshot?.allocations ?? []; const hostedNodes = Boolean(snapshot?.deployment.provider && snapshot.deployment.provider !== "e2b"); - const configurationKey = deployment - ? `${deployment.installation_id}:${deployment.owner_epoch}:${deployment.provider}:${deployment.mode}:${deployment.generation}` - : undefined; // Getting started asks for Add node on arrival. A request the first settled read cannot // serve (no own-machines deployment, active reset, a failed read) is dropped, so the // dialog never opens later on its own. @@ -259,7 +184,6 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig {dialog} {cleanupDialog} - {writeDialog} {localOnly && hostedNodes ? {tCommon("installationNotice.addBlocked")} : null} + {hostedNodes ? : null} {refreshButton} {hostedNodes && snapshot ? : null} ; return <> {enrollment} - +
{status} - {snapshot && !snapshot.deployment.provider && !snapshot.deployment.reset ? : null} + {snapshot && !hostedNodes ? navigate("system", { id: "sandbox" })}>{tSandboxNav("open")}} /> : null} + {snapshot?.deployment.reset && hostedNodes ?

{tSandboxNav("reset")}

: null} {snapshot?.deployment.provider ? <> {staleNodes.length ?

{staleNodes.length === 1 ? t("{{name}} is still bound to an old Core address. Remove it and add it again.", { name: staleNodes[0] }) : t("{{count}} nodes are still bound to an old Core address: {{names}}. Remove them and add them again.", { count: staleNodes.length, names: new Intl.ListFormat(i18n.resolvedLanguage, { type: "conjunction" }).format(staleNodes) })}

: null} - {hostedNodes ?
{nodes.length ? navigate("nodes", { id: node.id })} onRemove={askRemove} /> @@ -304,6 +229,5 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig
{dialog} {cleanupDialog} - {writeDialog} ; } diff --git a/apps/web/src/features/sandbox/SandboxPageAccess.tsx b/apps/web/src/features/sandbox/SandboxPageAccess.tsx new file mode 100644 index 000000000..277d8867b --- /dev/null +++ b/apps/web/src/features/sandbox/SandboxPageAccess.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import type { SandboxConsoleConfig } from "./console-config"; +import { sandboxConsoleConfigQuery } from "./sandbox-queries"; + +/** Both sandbox pages use the same console capability gate. */ +export function SandboxPageAccess({ header, children }: { header: ReactNode; children: (config: SandboxConsoleConfig) => ReactNode }) { + const { t } = useTranslation("sandbox"); + const { data: config, isPending: checking, isFetching, isError, refetch } = useQuery(sandboxConsoleConfigQuery); + if (isError && config === undefined) return <>{header}

{t("The console configuration could not be read. Refresh to try again.")}

; + if (checking) return <>{header}

{t("Connecting to this console's Core…")}

; + if (!config?.sandbox_admin) return <>{header}

{t("Sandbox administration is not configured on this console.")}

; + return children(config); +} diff --git a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx new file mode 100644 index 000000000..adb03fd2c --- /dev/null +++ b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx @@ -0,0 +1,78 @@ +import type { ReactElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { SandboxDeployment } from "@agents-core-web/agents-client"; +import { describe, expect, it, vi } from "vitest"; +import type { SandboxConsoleConfig } from "./console-config"; +import { SandboxManagerView } from "./SandboxManagerView"; +import { SandboxDeploymentPage } from "./SandboxDeploymentPage"; +import { SystemPage } from "../system/SystemPage"; +import { sandboxConsoleConfigQuery, sandboxDeploymentQuery, sandboxSnapshotQuery } from "./sandbox-queries"; +import { beginSandboxWrite, settleSandboxWrite } from "./sandbox-write-ownership"; + +// Portaled interactive flows are covered by browser acceptance; identify their page owner here. +vi.mock("./SandboxSetupWizard", () => ({ SandboxSetupWizard: () =>
setup-flow
})); +vi.mock("./NodeEnrollment", () => ({ NodeEnrollment: () =>
node-enrollment
})); + +function cache(provider: SandboxDeployment["provider"]) { + const client = new QueryClient({ defaultOptions: { queries: { enabled: false, retry: false, gcTime: Infinity } } }); + const deployment: SandboxDeployment = { + installation_id: "install", owner_epoch: 1, generation: 2, provider, core_url: "https://core.example", + mode: provider === "e2b" ? "direct" : "nodes", reset: null, resources: { allocations: 0, pending: 0 }, suspension: null, + rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null }, + }; + const config: SandboxConsoleConfig = { sandbox_admin: true, node_installer: false, node_installer_sha256: "", self_hosted_installer: false, self_hosted_installer_sha256: "" }; + client.setQueryData(sandboxConsoleConfigQuery.queryKey, () => config); + client.setQueryData(sandboxDeploymentQuery.queryKey, deployment); + client.setQueryData(sandboxSnapshotQuery.queryKey, { deployment, nodes: [], allocations: [], nodesError: null, readAt: 0 }); + return client; +} +const render = (page: ReactElement, client: QueryClient) => renderToStaticMarkup({page}); + +describe("sandbox page ownership", () => { + it("keeps setup in System's secondary page and sends empty Nodes there", () => { + const client = cache(""); + const nodes = render(, client); + expect(nodes).not.toContain("setup-flow"); + expect(nodes).toContain("Set up sandbox hosting in System before adding nodes."); + expect(nodes).toContain("Manage sandbox configuration"); + const configuration = render(, client); + expect(configuration).toContain("setup-flow"); + expect(configuration).toContain("Back to System"); + expect(configuration).not.toContain("node-enrollment"); + }); + + it("keeps Nodes as a node page for both cloud and host deployments", () => { + const cloud = render(, cache("e2b")); + expect(cloud).toContain("Nodes"); + expect(cloud).toContain("There are no nodes to manage."); + expect(cloud).not.toContain("Change resources"); + const hosts = render(, cache("docker")); + expect(hosts).toContain("node-enrollment"); + expect(hosts).toContain("Add your first node"); + expect(hosts).not.toContain("Reset deployment"); + }); + + it("opens editing deliberately and keeps the System landing page to one entry", () => { + const client = cache("docker"); + const configuration = render(, client); + expect(configuration).toContain("Change resources"); + expect(configuration).toContain("Reset deployment"); + expect(configuration).not.toContain("setup-flow"); + const system = render(, client); + expect(system).toContain("Manage sandbox configuration"); + expect(system).not.toContain("Change resources"); + expect(system).not.toContain("Reset deployment"); + }); + + it("shares pending and uncertain write ownership across both pages", () => { + const client = cache("docker"); + const attempt = beginSandboxWrite(client)!; + expect(render(, client)).toContain("Saving sandbox change"); + expect(render(, client)).toContain("Saving sandbox change"); + settleSandboxWrite(client, attempt); + for (const page of [, ]) { + expect(render(page, client)).toContain("Refresh sandbox state to confirm whether the change was saved before submitting again."); + } + }); +}); diff --git a/apps/web/src/features/sandbox/use-sandbox-page-state.ts b/apps/web/src/features/sandbox/use-sandbox-page-state.ts new file mode 100644 index 000000000..2df7d6b6c --- /dev/null +++ b/apps/web/src/features/sandbox/use-sandbox-page-state.ts @@ -0,0 +1,32 @@ +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { installationQuery } from "../../lib/installation"; +import { useSandboxManagerState } from "./use-sandbox-manager-state"; + +/** Shared observations and write admission survive navigation through the query cache. */ +export function useSandboxPageState() { + const state = useSandboxManagerState(); + const queryClient = useQueryClient(); + const installation = useQuery(installationQuery); + const { ownership, deploymentQuery, deployment, query } = state; + const loading = deploymentQuery.isFetching; + const busy = ownership.data.phase === "pending"; + const setupNeedsRefresh = ownership.data.phase === "reconcile"; + const confirmed = deployment !== undefined && !deploymentQuery.isError && ownership.data.phase === "idle"; + const fresh = confirmed && !loading; + const { refetch: refetchDeployment } = deploymentQuery; + const { refetch: refetchSnapshot } = query; + const refetch = useCallback(async () => { + const result = await refetchDeployment(); + if (!result.isError) await refetchSnapshot(); + return result; + }, [refetchDeployment, refetchSnapshot]); + const refresh = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: installationQuery.queryKey }); + return refetch(); + }, [queryClient, refetch]); + const configurationKey = deployment + ? `${deployment.installation_id}:${deployment.owner_epoch}:${deployment.provider}:${deployment.mode}:${deployment.generation}` + : undefined; + return { ...state, installation, loading, busy, setupNeedsRefresh, confirmed, fresh, refetch, refresh, configurationKey }; +} diff --git a/apps/web/src/features/system/SystemPage.tsx b/apps/web/src/features/system/SystemPage.tsx index 765cc8d08..208ef252a 100644 --- a/apps/web/src/features/system/SystemPage.tsx +++ b/apps/web/src/features/system/SystemPage.tsx @@ -1,30 +1,19 @@ -import type { SandboxResources } from "@agents-core-web/agents-client"; import { useQuery } from "@tanstack/react-query"; -import { Server } from "lucide-react"; -import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { EmptyState, PageBody, PageHeader, RefreshButton, Section, StatusDot } from "../../components/console-ui"; +import { PageBody, PageHeader, RefreshButton, Section } from "../../components/console-ui"; import { ErrorState } from "../../components/ErrorState"; import { CopyableId } from "../../components/list-ui"; import { TableSkeleton } from "../../components/Skeleton"; import { useConsoleNavigation } from "../../lib/console-navigation"; -import { formatBytes, formatPeriod } from "../../lib/format"; import { InstallationNotice } from "../../components/InstallationNotice"; import { installationQuery } from "../../lib/installation"; -import { sandboxSize, templateBuildSize, templateBuildStatus } from "../sandbox/deployment-specification"; -import { sandboxDeploymentQuery } from "../sandbox/sandbox-queries"; -import { SandboxRolloutSummary } from "../sandbox/SandboxRolloutSummary"; -import { SandboxResetNotice } from "../fleet/SandboxResetNotice"; import { DefaultModelsSection } from "./DefaultModelsSection"; import { Fact } from "./Fact"; import { harnessesQuery } from "./harness-queries"; import { StartupSettings } from "./StartupSettings"; import "./system.css"; -const MIB = 2 ** 20; -const providerNames: Record = { docker: "Docker", microsandbox: "microsandbox" }; - /** A facts card before its first read; the page announces its loading once. */ function FactsSkeleton({ facts }: { facts: number }) { return ( @@ -39,78 +28,15 @@ function FactsSkeleton({ facts }: { facts: number }) { ); } -/** - * Platform › System: this installation's addresses; each harness's default - * model for Core-hosted Sessions and Sessions without an environment, set - * here; the sandbox configuration every project shares — where sandboxes run, - * how big each one is, the Runtime or E2B template build they run, - * microsandbox's idle suspension and sandbox reset progress — and Core's startup - * settings. Each says where it is changed: sandboxes on the Nodes page, - * startup settings in config.json. - */ +/** System owns installation settings and the entry to sandbox configuration. */ export function SystemPage() { - const { t, i18n } = useTranslation("system"); - const locale = i18n.resolvedLanguage; + const { t } = useTranslation("system"); + const { t: tSandboxNav } = useTranslation("sandboxNavigation"); const { t: tNav } = useTranslation("navigation"); const { navigate } = useConsoleNavigation(); - const deployment = useQuery(sandboxDeploymentQuery); const installation = useQuery(installationQuery); const harnesses = useQuery(harnessesQuery); - const data = deployment.data ?? null; - const refresh = () => { void deployment.refetch(); void installation.refetch(); void harnesses.refetch(); }; - const spec = data?.specification ?? null; - // An E2B selection may adopt its template build's size instead of saving one. - const each = data ? sandboxSize(data) : null; - const build = data?.e2b?.template_build; - const buildSize = data ? templateBuildSize(data) : null; - const buildStatus = templateBuildStatus(build); - const size = (resources: SandboxResources) => t("sandbox.size", { count: resources.cpus, memory: formatBytes(resources.memory_mib * MIB) }); - - let body: ReactNode; - if (!data) { - body = deployment.isError && !deployment.isFetching - ? - : ; - } else if (!data.provider) { - body = ( - navigate("nodes")}>{t("sandbox.setUp")}} - /> - ); - } else { - body = ( -
navigate("nodes")}>{t(data.provider === "e2b" ? "sandbox.changeCloud" : "sandbox.change")}}> -
- {data.provider === "e2b" ? t("sandbox.e2b") : t("sandbox.ownMachines", { provider: providerNames[data.provider] ?? data.provider })} - {each ? ( - - {size(each)} - {each.root_disk_mib ? {t("sandbox.disks", { root: formatBytes(each.root_disk_mib * MIB), data: formatBytes((each.environment_disk_mib ?? 0) * MIB) })} : null} - - ) : null} - {spec?.runtime ? {spec.runtime.source_commit.slice(0, 12)} : null} - {data.e2b?.template ? {data.e2b.template} : null} - {data.e2b ? ( - - {buildStatus === "notReady" ? : t(`sandbox.buildStatus.${buildStatus}`)} - {buildSize ? ( - - {size(buildSize)} - {build?.resources.root_disk_mib != null ? ` · ${t("sandbox.buildDisk", { disk: formatBytes(build.resources.root_disk_mib * MIB) })}` : null} - - ) : null} - - ) : null} - {data.suspension ? <> - {formatPeriod(data.suspension.idle_seconds, locale)} - {formatPeriod(data.suspension.retention_seconds, locale)} - : null} -
-
- ); - } + const refresh = () => { void installation.refetch(); void harnesses.refetch(); }; const about = installation.data; const facts = about ? ( @@ -129,7 +55,7 @@ export function SystemPage() { ? void installation.refetch()} /> : ; // One announcement while either first read is still out; each slot shows only its own placeholder. - const reading = (!data && !(deployment.isError && !deployment.isFetching)) || (!about && !(installation.isError && !installation.isFetching)) || + const reading = (!about && !(installation.isError && !installation.isFetching)) || (!harnesses.data && !(harnesses.isError && !harnesses.isFetching)); return ( @@ -138,16 +64,16 @@ export function SystemPage() { headingId="system-heading" title={tNav("views.system")} help={t("help")} - actions={} + actions={} /> {reading ?

{t("loading")}

: null} {facts} - void deployment.refetch()} /> - {deployment.data ? navigate("nodes")} /> : null} - {body} +
+ +
{about ? : installation.isError ? null : }
diff --git a/apps/web/src/i18n/locales/en/sandbox-navigation.ts b/apps/web/src/i18n/locales/en/sandbox-navigation.ts new file mode 100644 index 000000000..a912d4e16 --- /dev/null +++ b/apps/web/src/i18n/locales/en/sandbox-navigation.ts @@ -0,0 +1,11 @@ +export const sandboxNavigation = { + "title": "Sandbox configuration", + "help": "Manage the deployment backend, sandbox size and reset here. Nodes manages your machines.", + "open": "Manage sandbox configuration", + "back": "Back to System", + "nodes": "Open Nodes", + "unconfigured": "Set up sandbox hosting in System before adding nodes.", + "cloud": "E2B runs sandboxes in its cloud. There are no nodes to manage.", + "reset": "Node enrollment is paused during reset. View progress in System.", + "details": "Configuration details" +} as const; diff --git a/apps/web/src/i18n/locales/zh-CN/sandbox-navigation.ts b/apps/web/src/i18n/locales/zh-CN/sandbox-navigation.ts new file mode 100644 index 000000000..5c9a5431c --- /dev/null +++ b/apps/web/src/i18n/locales/zh-CN/sandbox-navigation.ts @@ -0,0 +1,11 @@ +export const sandboxNavigation = { + "title": "沙箱配置", + "help": "在这里管理部署后端、沙箱规格和重置;主机在节点页面管理。", + "open": "管理沙箱配置", + "back": "返回系统", + "nodes": "打开节点", + "unconfigured": "请先在系统中配置沙箱托管,再添加节点。", + "cloud": "E2B 在云端运行沙箱,无需管理节点。", + "reset": "重置期间暂停添加节点,请在系统中查看进度。", + "details": "配置详情" +} as const; From 539c4b07c17e09c6fabbc732cd5bc95cbb5e58da Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 09:50:34 +0000 Subject: [PATCH 11/16] Present Core diagnostics and simplify console configuration ownership --- apps/web/DESIGN.md | 44 +++++++--- apps/web/PRODUCT.md | 62 +++++++++----- apps/web/e2e/diagnostics.spec.ts | 85 +++++++++++++++++++ apps/web/e2e/fixture-console.mjs | 17 +++- apps/web/e2e/provider-observations.spec.ts | 41 +++++++++ apps/web/e2e/wording.spec.ts | 4 +- apps/web/src/components/ConsoleSidebar.tsx | 9 +- .../src/features/fleet/SandboxResetNotice.tsx | 2 +- .../features/metrics/SandboxMetricsPage.tsx | 6 +- .../src/features/overview/GettingStarted.tsx | 6 +- .../src/features/overview/OverviewPage.tsx | 11 ++- .../features/overview/getting-started.test.ts | 2 +- .../features/sandbox/SandboxResetControls.tsx | 12 ++- .../features/sandbox/reset-confirmation.css | 4 + .../src/features/sessions/SessionLogPage.tsx | 2 +- .../web/src/features/sessions/SessionPage.tsx | 8 +- .../src/features/sessions/SessionStatus.tsx | 4 +- .../features/sessions/SessionTranscript.tsx | 4 +- .../features/sessions/SessionTurnsTable.tsx | 5 +- .../sessions/session-diagnostics.test.tsx | 80 +++++++++++++++++ .../features/sessions/session-diagnostics.tsx | 64 ++++++++++++++ .../src/features/sessions/session-queries.ts | 20 +---- .../sessions/trace/ItemReceiptTiming.tsx | 43 ++++++++++ .../sessions/trace/TraceTimingPanel.tsx | 21 +++++ .../src/features/sessions/trace/TraceView.tsx | 20 +---- .../sessions/trace/receipt-timing.css | 5 ++ .../features/system/DefaultModelsSection.tsx | 55 ++---------- apps/web/src/features/system/HarnessCard.tsx | 53 ++++++++++++ .../features/system/ProviderObservations.tsx | 36 ++++++++ .../system/provider-observations.test.tsx | 29 +++++++ apps/web/src/i18n/locales/en/diagnostics.ts | 58 +++++++++++++ .../web/src/i18n/locales/zh-CN/diagnostics.ts | 58 +++++++++++++ apps/web/src/i18n/resources.ts | 8 ++ apps/web/src/lib/diagnostic-failure.ts | 16 ++++ apps/web/src/lib/locale-strings.ts | 4 + 35 files changed, 742 insertions(+), 156 deletions(-) create mode 100644 apps/web/e2e/diagnostics.spec.ts create mode 100644 apps/web/e2e/provider-observations.spec.ts create mode 100644 apps/web/src/features/sandbox/reset-confirmation.css create mode 100644 apps/web/src/features/sessions/session-diagnostics.test.tsx create mode 100644 apps/web/src/features/sessions/session-diagnostics.tsx create mode 100644 apps/web/src/features/sessions/trace/ItemReceiptTiming.tsx create mode 100644 apps/web/src/features/sessions/trace/TraceTimingPanel.tsx create mode 100644 apps/web/src/features/sessions/trace/receipt-timing.css create mode 100644 apps/web/src/features/system/HarnessCard.tsx create mode 100644 apps/web/src/features/system/ProviderObservations.tsx create mode 100644 apps/web/src/features/system/provider-observations.test.tsx create mode 100644 apps/web/src/i18n/locales/en/diagnostics.ts create mode 100644 apps/web/src/i18n/locales/zh-CN/diagnostics.ts create mode 100644 apps/web/src/lib/diagnostic-failure.ts diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 1bed360ac..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 @@ -767,8 +767,8 @@ or auto-resubmit. Optional explanations sit behind help tips; errors and safety consequences remain visible. ### Configuration generations -Use the existing compact fact panel for Core's target generation, previous-generation -sandboxes and rollout counts. Poll rapidly only while Core reports preparing, or +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 @@ -789,12 +789,14 @@ 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 @@ -811,9 +813,9 @@ successful facts with a visible stale/unavailable notice. An uncertain write ope 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. Submitting consumes the reset confirmation even if its outcome is uncertain; @@ -832,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 b8c548b20..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 @@ -133,8 +122,8 @@ workbench. 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: the Nodes entry becomes Sandbox backend, - and Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud +- **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 @@ -178,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 @@ -198,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 @@ -205,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 @@ -216,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/diagnostics.spec.ts b/apps/web/e2e/diagnostics.spec.ts new file mode 100644 index 000000000..f5c27068c --- /dev/null +++ b/apps/web/e2e/diagnostics.spec.ts @@ -0,0 +1,85 @@ +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 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: 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.getByLabel("Host connection")).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(); + connected = true; + await expect(page.getByLabel("Host connection")).toContainText("Connected", { timeout: 10_000 }); + await expect(page.getByText("Run on host · Done", { exact: true })).toBeVisible(); + unavailable = true; + await expect(page.getByLabel("Host connection")).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 fc0f8e4a7..b79218fd6 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -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") { @@ -552,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); 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/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/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 (