Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ The console runs beside the administrator's own Core, with execution, files and
- A node whose provider is not ready names the reason as one Provider-neutral readiness class (provider unavailable, host unsupported, provider files or Runtime image missing, Runtime download failed, 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 in Settings → Sandbox and scheduling. 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 Core forces the remaining work. It does not affect self-hosted execution. Histories and persisted Files/Artifacts remain; archived Sessions cannot resume, and unpersisted workspace contents may be lost. Cancel stops further clearing 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.
- **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.
- **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. Changes keep the saved Runtime. When it differs from the Runtime this console serves, Sandbox and scheduling adds a Runtime row with both source commits and Use this installation's Runtime, which opens Change resources on its review with that Runtime; a console without a Runtime manifest leaves the release to Advanced settings. Configuration rollout shows Core's target preparation and retained previous-generation sandbox count. A settled rollout can still have failed, update-required or unknown nodes and old resources. An offline node stays offline even when it has a recorded serving generation. Node and allocation detail distinguish the serving pin, target preparation and each resource's configuration generation.
- **E2B credential replacement** uses the same configuration form. Setup requires a key; leaving it blank during an update keeps the saved key. An explicit key, even the same value, is verified as a replacement and advances the target generation after successful verification. Another backend or E2B team requires a deliberate reset. A rejected or uncertain replacement never clears the committed configuration or replays the write.
- **E2B deployments** have no machines: Nodes offers a link to Settings → Sandbox and scheduling. Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud (running, starting, size, template build) instead of node capacity, with no Add node action; a sandbox's dialog adds its disk use.
- **Automatic suspension** appears when the deployment declares a suspension policy. Nodes shows each node's suspended count and retained limit, the most sandboxes it keeps running and suspended together, with Edit node for its limits; Overview adds the suspended total of all registered nodes under its sandbox slots. Settings → Sandbox and scheduling owns **Automatic suspension**: deployment-wide idle seconds are editable there; snapshot retention remains read-only. Necessary policy explanations belong in help tips, not persistent helper paragraphs. These are Core records, not measured CPU or memory savings. Node allocations show the recorded compute phase and elapsed phase time, never an inferred snapshot-expiry countdown. Sandbox metrics' Hosted Runtimes has one Status column: Core's lifecycle record of each sandbox (Active, Suspended, Changing state, Waiting for allocation or Stopped), which Core derives from the allocation's recorded phase. The Session page's Runtime state reads the same way. When an active sandbox has no sample, its CPU and Memory cells name why. Independent workspace persistence is explained only when the deployment declares it. Nodes refreshes its inventory every 30 seconds while visible; reset and active preparation retain their faster polling.
Expand Down
16 changes: 16 additions & 0 deletions apps/web/e2e/sandbox-generation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,22 @@ test("online configuration retains existing resources and stops rapid polling wh
expect(await writes(request)).toEqual([`PUT ${deploymentPath}`]);
});

test("a saved Runtime moves to the one this installation serves through the reviewed update", async ({ page, request }) => {
await openConsole(page, request, "settings?id=sandbox");
const served = await (await page.request.get(new URL("/node-install/manifest.json", page.url()).href)).json() as { source_commit: string; images: { runtime: string } };
const hex = (c: string) => c.repeat(64);
const saved = { source_commit: "a".repeat(40), image_id: `sha256:${hex("a")}`, image_manifest_digest: `sha256:${hex("b")}`, microsandbox_ref: `oac-runtime@sha256:${hex("c")}`, runtime_sha256: hex("d"), firmware_sha256: hex("e") };
await setDeployment(request, { specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: saved } });
await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click();
await expect(page.getByText(`Saved ${saved.source_commit.slice(0, 12)} · this installation serves ${served.source_commit.slice(0, 12)}`)).toBeVisible();
const use = page.getByRole("button", { name: "Use this installation's Runtime", exact: true });
await use.click();
const sent = await saveConfiguration(page);
expect(sent.input.runtime).toMatchObject({ source_commit: served.source_commit, image_id: served.images.runtime });
await expect(use).toHaveCount(0);
await inspectRollout(page, { "Core preparation": "Preparing configuration", "Target generation": "2" });
});

test("unknown target preparation preserves live old-generation service while an offline pin stays offline", async ({ page, request }, info) => {
await page.setViewportSize({ width: 1280, height: 1000 });
await page.emulateMedia({ colorScheme: "light", reducedMotion: "reduce" });
Expand Down
22 changes: 18 additions & 4 deletions apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { useState, type ReactNode } from "react";
import type { UpdateSandboxDeployment, SandboxDeployment } from "@oac/agents-client";
import { useTranslation } from "react-i18next";
import type { UpdateSandboxDeployment, SandboxDeployment, SandboxRuntimeRelease } from "@oac/agents-client";
import { useQuery } from "@tanstack/react-query";
import { Trans, useTranslation } from "react-i18next";
import { Modal } from "../../components/Modal";
import { HelpTip, RefreshButton } from "../../components/console-ui";
import { formatBytes, formatPeriod, MISSING } from "../../lib/format";
import type { ParseKeys } from "i18next";
import { sandboxProviderLabel } from "../../lib/sandbox-labels";
import { Fact } from "../system/Fact";
import { sandboxSize, templateBuildSize, templateBuildStatus } from "./deployment-specification";
import { RUNTIME_RELEASE_FIELDS, sandboxSize, templateBuildSize, templateBuildStatus } from "./deployment-specification";
import { distributionRuntimeQuery } from "./sandbox-queries";
import { SandboxRolloutSummary } from "./SandboxRolloutSummary";
import { SandboxSetupWizard } from "./SandboxSetupWizard";

Expand All @@ -29,13 +31,21 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat
const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en";
const [changing, setChanging] = useState(false);
const [editKey, setEditKey] = useState(0);
const [proposedRuntime, setProposedRuntime] = useState<SandboxRuntimeRelease>();
const spec = deployment.specification;
// Only Docker and microsandbox save a Runtime. Changes keep it; moving to the
// one this console serves is an explicit change of its own.
const savedRuntime = spec?.runtime;
const served = useQuery({ ...distributionRuntimeQuery, enabled: Boolean(savedRuntime) }).data;
const offered = savedRuntime && served && !deployment.reset && RUNTIME_RELEASE_FIELDS.some((field) => savedRuntime[field] !== served[field]) ? served : null;
// An E2B selection may adopt its template build's size instead of saving one.
const size = sandboxSize(deployment);
const build = deployment.metadata?.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 canEdit = fresh && !deployment.reset;
const change = (runtime?: SandboxRuntimeRelease) => { setEditKey((key) => key + 1); setProposedRuntime(runtime); setChanging(true); };
const short = (release: SandboxRuntimeRelease) => <code className="system-code" title={release.source_commit}>{release.source_commit.slice(0, 12)}</code>;
return <section className="console-section" aria-labelledby="sandbox-provider-heading">
<header className="console-section-header">
<div className="console-section-title">
Expand All @@ -53,12 +63,15 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat
</div>
{!deployment.reset ? <div className="console-section-actions">
<HelpTip>{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.")}</HelpTip>
<button type="button" className="button outline" disabled={disabled || !canEdit || changing} onClick={() => { setEditKey((key) => key + 1); setChanging(true); }}>{t("Change resources")}</button>
<button type="button" className="button outline" disabled={disabled || !canEdit || changing} onClick={() => change()}>{t("Change resources")}</button>
</div> : null}
</header>
<div className="settings-rows">
<Fact label={t("Provider")}>{sandboxProviderLabel(deployment.provider, locale)}</Fact>
<Fact label={t("Each sandbox")} help={spec?.workspace && !spec.workspace.capacity_quota ? t("Workspace storage is configured by the operator; no capacity quota is enforced.") : undefined}>{size ? `${sizeLabel(size)}${size.root_disk_mib ? ` · ${spec?.workspace ? t("Root disk {{root}} · external workspace", { root: formatBytes(size.root_disk_mib * MIB) }) : t("Root disk {{root}} · data disk {{data}}", { root: formatBytes(size.root_disk_mib * MIB), data: formatBytes((size.environment_disk_mib ?? 0) * MIB) })}` : ""}` : MISSING}</Fact>
{savedRuntime && offered ? <Fact label={t("Runtime")} help={t("Other changes keep the saved Runtime. Existing sandboxes keep their Runtime while nodes prepare the new one.")} action={<button type="button" className="button outline" disabled={disabled || !canEdit || changing} onClick={() => change(offered)}>{t("Use this installation's Runtime")}</button>}>
<Trans t={t} i18nKey="Saved <saved/> · this installation serves <served/>" components={{ saved: short(savedRuntime), served: short(offered) }} />
</Fact> : null}
{deployment.provider === "e2b" ? <>
<Fact label={t("Sandbox API URL")}><code className="system-code">{deployment.configuration?.api_url}</code></Fact>
<Fact label={t("Sandbox data-plane domain")}><code className="system-code">{deployment.configuration?.domain}</code></Fact>
Expand All @@ -79,6 +92,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat
current={deployment.provider ? { provider: deployment.provider, specification: deployment.specification, e2bTemplate: deployment.configuration?.template, e2bAPIURL: deployment.configuration?.api_url, e2bDomain: deployment.configuration?.domain } : undefined}
disabled={disabled || !canEdit}
editing
proposedRuntime={proposedRuntime}
onSubmit={async (input) => { if (await onUpdate(input)) setChanging(false); }}
/>
{writeFailure}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SandboxDeployment } from "@oac/agents-client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { node } from "../overview/test-fixtures";
Expand Down Expand Up @@ -51,7 +52,7 @@ describe("authoritative configuration rollout", () => {
});

it("allows same-provider online editing with held resources but keeps reset active as a boundary", () => {
const render = (value: SandboxDeployment) => renderToStaticMarkup(<SandboxDeploymentSettings deployment={value} fresh disabled={false} onUpdate={async () => true} onRefresh={() => undefined} refreshing={false} pending={false} />);
const render = (value: SandboxDeployment) => renderToStaticMarkup(<QueryClientProvider client={new QueryClient()}><SandboxDeploymentSettings deployment={value} fresh disabled={false} onUpdate={async () => true} onRefresh={() => undefined} refreshing={false} pending={false} /></QueryClientProvider>);
const html = render(deployment);
expect(html).toMatch(/<button[^>]*class="button outline"[^>]*>Change resources<\/button>/);
expect(html).not.toMatch(/<button[^>]*disabled[^>]*>Change resources<\/button>/);
Expand Down
16 changes: 9 additions & 7 deletions apps/web/src/features/sandbox/SandboxSetupWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import { formatBytes } from "../../lib/format";
import { installationQuery } from "../../lib/installation";
import type { ParseKeys } from "i18next";
import { sandboxConfigurationRejection, sandboxProviderLabel } from "../../lib/sandbox-labels";
import { defaultSandboxResources, distributionRuntime, isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS, savedSpecification, validSandboxResources } from "./deployment-specification";
import { defaultSandboxResources, isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS, savedSpecification, validSandboxResources } from "./deployment-specification";
import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update";
import { sandboxAdmin } from "./sandbox-queries";
import { distributionRuntimeQuery, sandboxAdmin } from "./sandbox-queries";
import "./sandbox-wizard.css";

type Where = "nodes" | "direct";
Expand Down Expand Up @@ -118,16 +118,18 @@ type WizardProps = {
expectedGeneration: number;
current?: { provider: SandboxProvider; specification?: SandboxSpecification; e2bTemplate?: string; e2bAPIURL?: string; e2bDomain?: string };
disabled: boolean;
/** A Runtime release an update proposes; the wizard opens on its review. */
proposedRuntime?: SandboxRuntimeRelease;
} & ({ editing: true; onSubmit: (input: UpdateSandboxDeployment) => Promise<void> } |
{ editing?: false; onSubmit: (input: InitializeSandboxDeployment) => Promise<void> });

export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disabled, editing, onSubmit }: WizardProps) {
export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disabled, editing, proposedRuntime, onSubmit }: WizardProps) {
const { t, i18n } = useTranslation("sandbox");
const { t: tCommon } = useTranslation("common");
const id = useId();
const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en";
const currentMode: Where | null = current ? deploymentContract.providers[current.provider].mode : null;
const [step, setStep] = useState<Step>(editing ? currentMode === "direct" ? "e2b" : "size" : "where");
const [step, setStep] = useState<Step>(proposedRuntime ? "review" : editing ? currentMode === "direct" ? "e2b" : "size" : "where");
const [where, setWhere] = useState<Where | null>(currentMode);
const [provider, setProvider] = useState<SandboxProvider | null>(current?.provider ?? null);
const policy = provider ? deploymentContract.providers[provider] : null;
Expand All @@ -149,7 +151,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
const [discovery, setDiscovery] = useState<"idle" | "loading" | "ready" | "error">("idle");
const [buildDiscovery, setBuildDiscovery] = useState<"idle" | "loading" | "ready" | "error">("idle");
const [discoveryRetry, setDiscoveryRetry] = useState(0);
const [runtime, setRuntime] = useState<Partial<SandboxRuntimeRelease>>({});
const [runtime, setRuntime] = useState<Partial<SandboxRuntimeRelease>>(proposedRuntime ?? {});
const [busy, setBusy] = useState(false);
const [dockerConfirmed, setDockerConfirmed] = useState(current?.provider === "docker");
const [confirmingDocker, setConfirmingDocker] = useState(false);
Expand Down Expand Up @@ -193,9 +195,9 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
setTemplate(""); setSelectedTemplate(""); setTemplates([]); setBuilds([]);
}

// A release the administrator entered comes first, then the saved one of the same backend,
// A release the administrator entered or an update proposes comes first, then the saved one of the same backend,
// then the one this console distributes (the release its node installer verifies).
const matched = useQuery({ queryKey: ["sandbox-runtime-release"], queryFn: ({ signal }) => distributionRuntime(signal).catch(() => null), staleTime: Infinity, retry: false });
const matched = useQuery(distributionRuntimeQuery);
const release: Partial<SandboxRuntimeRelease> = Object.keys(runtime).length ? runtime : saved?.runtime ?? matched.data ?? {};

const needsRuntime = policy?.runtime ?? false;
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/features/sandbox/sandbox-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { queryOptions, useQuery } from "@tanstack/react-query";

import { confirmSandboxRead, startSandboxRead } from "./sandbox-write-ownership";
import { sandboxConsoleConfig } from "./console-config";
import { distributionRuntime } from "./deployment-specification";

/**
* Cache entries for the sandbox deployment (`/core/v1/sandbox`). Every read
Expand All @@ -24,6 +25,14 @@ export const sandboxConsoleConfigQuery = queryOptions({
},
});

/** The Runtime release of this console's distribution manifest, the release its node installer verifies; null when it serves none. */
export const distributionRuntimeQuery = queryOptions({
queryKey: ["sandbox-runtime-release"],
queryFn: ({ signal }) => distributionRuntime(signal).catch(() => null),
staleTime: Infinity,
retry: false,
});

/** The deployment alone, for pages that only describe it. */
export const sandboxDeploymentQuery = queryOptions<SandboxDeployment>({
queryKey: [...sandboxScope, "deployment"],
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/settings/settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
overflow-wrap: anywhere;
}

.settings-row-content { min-width: 0; flex: 1; }
.settings-row-content { min-width: 0; flex: 1 1 auto; }
.settings-row-action { display: flex; flex: none; gap: 8px; margin-left: auto; }

.settings-row pre {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/i18n/locales/zh-CN/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,9 @@ export const sandbox = {
"Root disk (MiB)": "根盘(MiB)",
"Root disk {{root}} · data disk {{data}}": "根盘 {{root}} · 数据盘 {{data}}",
"Runtime": "Runtime",
"Saved <saved/> · this installation serves <served/>": "已保存 <saved/> · 本安装提供 <served/>",
"Other changes keep the saved Runtime. Existing sandboxes keep their Runtime while nodes prepare the new one.": "其他修改会保留已保存的 Runtime。节点准备新 Runtime 期间,现有沙箱继续使用原来的 Runtime。",
"Use this installation's Runtime": "使用本安装的 Runtime",
"Runtime SHA-256": "Runtime SHA-256",
"Runtime release": "Runtime 版本",
"Runtime release needed": "需要 Runtime 版本",
Expand Down
Loading
Loading