Skip to content

Commit daeb754

Browse files
authored
Offer the installation's Runtime after an upgrade (#703)
1 parent d8ff156 commit daeb754

12 files changed

Lines changed: 67 additions & 20 deletions

File tree

‎apps/web/PRODUCT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ The console runs beside the administrator's own Core, with execution, files and
3535
- 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.
3636
- 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.
3737
- **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.
38-
- **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.
38+
- **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.
3939
- **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.
4040
- **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.
4141
- **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.

‎apps/web/e2e/sandbox-generation.spec.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,22 @@ test("online configuration retains existing resources and stops rapid polling wh
105105
expect(await writes(request)).toEqual([`PUT ${deploymentPath}`]);
106106
});
107107

108+
test("a saved Runtime moves to the one this installation serves through the reviewed update", async ({ page, request }) => {
109+
await openConsole(page, request, "settings?id=sandbox");
110+
const served = await (await page.request.get(new URL("/node-install/manifest.json", page.url()).href)).json() as { source_commit: string; images: { runtime: string } };
111+
const hex = (c: string) => c.repeat(64);
112+
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") };
113+
await setDeployment(request, { specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: saved } });
114+
await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click();
115+
await expect(page.getByText(`Saved ${saved.source_commit.slice(0, 12)} · this installation serves ${served.source_commit.slice(0, 12)}`)).toBeVisible();
116+
const use = page.getByRole("button", { name: "Use this installation's Runtime", exact: true });
117+
await use.click();
118+
const sent = await saveConfiguration(page);
119+
expect(sent.input.runtime).toMatchObject({ source_commit: served.source_commit, image_id: served.images.runtime });
120+
await expect(use).toHaveCount(0);
121+
await inspectRollout(page, { "Core preparation": "Preparing configuration", "Target generation": "2" });
122+
});
123+
108124
test("unknown target preparation preserves live old-generation service while an offline pin stays offline", async ({ page, request }, info) => {
109125
await page.setViewportSize({ width: 1280, height: 1000 });
110126
await page.emulateMedia({ colorScheme: "light", reducedMotion: "reduce" });

‎apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { useState, type ReactNode } from "react";
2-
import type { UpdateSandboxDeployment, SandboxDeployment } from "@oac/agents-client";
3-
import { useTranslation } from "react-i18next";
2+
import type { UpdateSandboxDeployment, SandboxDeployment, SandboxRuntimeRelease } from "@oac/agents-client";
3+
import { useQuery } from "@tanstack/react-query";
4+
import { Trans, useTranslation } from "react-i18next";
45
import { Modal } from "../../components/Modal";
56
import { HelpTip, RefreshButton } from "../../components/console-ui";
67
import { formatBytes, formatPeriod, MISSING } from "../../lib/format";
78
import type { ParseKeys } from "i18next";
89
import { sandboxProviderLabel } from "../../lib/sandbox-labels";
910
import { Fact } from "../system/Fact";
10-
import { sandboxSize, templateBuildSize, templateBuildStatus } from "./deployment-specification";
11+
import { RUNTIME_RELEASE_FIELDS, sandboxSize, templateBuildSize, templateBuildStatus } from "./deployment-specification";
12+
import { distributionRuntimeQuery } from "./sandbox-queries";
1113
import { SandboxRolloutSummary } from "./SandboxRolloutSummary";
1214
import { SandboxSetupWizard } from "./SandboxSetupWizard";
1315

@@ -29,13 +31,21 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat
2931
const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en";
3032
const [changing, setChanging] = useState(false);
3133
const [editKey, setEditKey] = useState(0);
34+
const [proposedRuntime, setProposedRuntime] = useState<SandboxRuntimeRelease>();
3235
const spec = deployment.specification;
36+
// Only Docker and microsandbox save a Runtime. Changes keep it; moving to the
37+
// one this console serves is an explicit change of its own.
38+
const savedRuntime = spec?.runtime;
39+
const served = useQuery({ ...distributionRuntimeQuery, enabled: Boolean(savedRuntime) }).data;
40+
const offered = savedRuntime && served && !deployment.reset && RUNTIME_RELEASE_FIELDS.some((field) => savedRuntime[field] !== served[field]) ? served : null;
3341
// An E2B selection may adopt its template build's size instead of saving one.
3442
const size = sandboxSize(deployment);
3543
const build = deployment.metadata?.template_build;
3644
const buildSize = templateBuildSize(deployment);
3745
const sizeLabel = (value: { cpus: number; memory_mib: number }) => t("{{cpus}} CPU · {{memory}}", { cpus: value.cpus, memory: formatBytes(value.memory_mib * MIB) });
3846
const canEdit = fresh && !deployment.reset;
47+
const change = (runtime?: SandboxRuntimeRelease) => { setEditKey((key) => key + 1); setProposedRuntime(runtime); setChanging(true); };
48+
const short = (release: SandboxRuntimeRelease) => <code className="system-code" title={release.source_commit}>{release.source_commit.slice(0, 12)}</code>;
3949
return <section className="console-section" aria-labelledby="sandbox-provider-heading">
4050
<header className="console-section-header">
4151
<div className="console-section-title">
@@ -53,12 +63,15 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat
5363
</div>
5464
{!deployment.reset ? <div className="console-section-actions">
5565
<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>
56-
<button type="button" className="button outline" disabled={disabled || !canEdit || changing} onClick={() => { setEditKey((key) => key + 1); setChanging(true); }}>{t("Change resources")}</button>
66+
<button type="button" className="button outline" disabled={disabled || !canEdit || changing} onClick={() => change()}>{t("Change resources")}</button>
5767
</div> : null}
5868
</header>
5969
<div className="settings-rows">
6070
<Fact label={t("Provider")}>{sandboxProviderLabel(deployment.provider, locale)}</Fact>
6171
<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>
72+
{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>}>
73+
<Trans t={t} i18nKey="Saved <saved/> · this installation serves <served/>" components={{ saved: short(savedRuntime), served: short(offered) }} />
74+
</Fact> : null}
6275
{deployment.provider === "e2b" ? <>
6376
<Fact label={t("Sandbox API URL")}><code className="system-code">{deployment.configuration?.api_url}</code></Fact>
6477
<Fact label={t("Sandbox data-plane domain")}><code className="system-code">{deployment.configuration?.domain}</code></Fact>
@@ -79,6 +92,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat
7992
current={deployment.provider ? { provider: deployment.provider, specification: deployment.specification, e2bTemplate: deployment.configuration?.template, e2bAPIURL: deployment.configuration?.api_url, e2bDomain: deployment.configuration?.domain } : undefined}
8093
disabled={disabled || !canEdit}
8194
editing
95+
proposedRuntime={proposedRuntime}
8296
onSubmit={async (input) => { if (await onUpdate(input)) setChanging(false); }}
8397
/>
8498
{writeFailure}

‎apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { SandboxDeployment } from "@oac/agents-client";
2+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
23
import { renderToStaticMarkup } from "react-dom/server";
34
import { describe, expect, it } from "vitest";
45
import { node } from "../overview/test-fixtures";
@@ -51,7 +52,7 @@ describe("authoritative configuration rollout", () => {
5152
});
5253

5354
it("allows same-provider online editing with held resources but keeps reset active as a boundary", () => {
54-
const render = (value: SandboxDeployment) => renderToStaticMarkup(<SandboxDeploymentSettings deployment={value} fresh disabled={false} onUpdate={async () => true} onRefresh={() => undefined} refreshing={false} pending={false} />);
55+
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>);
5556
const html = render(deployment);
5657
expect(html).toMatch(/<button[^>]*class="button outline"[^>]*>Change resources<\/button>/);
5758
expect(html).not.toMatch(/<button[^>]*disabled[^>]*>Change resources<\/button>/);

‎apps/web/src/features/sandbox/SandboxSetupWizard.tsx‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import { formatBytes } from "../../lib/format";
1515
import { installationQuery } from "../../lib/installation";
1616
import type { ParseKeys } from "i18next";
1717
import { sandboxConfigurationRejection, sandboxProviderLabel } from "../../lib/sandbox-labels";
18-
import { defaultSandboxResources, distributionRuntime, isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS, savedSpecification, validSandboxResources } from "./deployment-specification";
18+
import { defaultSandboxResources, isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS, savedSpecification, validSandboxResources } from "./deployment-specification";
1919
import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update";
20-
import { sandboxAdmin } from "./sandbox-queries";
20+
import { distributionRuntimeQuery, sandboxAdmin } from "./sandbox-queries";
2121
import "./sandbox-wizard.css";
2222

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

124-
export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disabled, editing, onSubmit }: WizardProps) {
126+
export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disabled, editing, proposedRuntime, onSubmit }: WizardProps) {
125127
const { t, i18n } = useTranslation("sandbox");
126128
const { t: tCommon } = useTranslation("common");
127129
const id = useId();
128130
const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en";
129131
const currentMode: Where | null = current ? deploymentContract.providers[current.provider].mode : null;
130-
const [step, setStep] = useState<Step>(editing ? currentMode === "direct" ? "e2b" : "size" : "where");
132+
const [step, setStep] = useState<Step>(proposedRuntime ? "review" : editing ? currentMode === "direct" ? "e2b" : "size" : "where");
131133
const [where, setWhere] = useState<Where | null>(currentMode);
132134
const [provider, setProvider] = useState<SandboxProvider | null>(current?.provider ?? null);
133135
const policy = provider ? deploymentContract.providers[provider] : null;
@@ -149,7 +151,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
149151
const [discovery, setDiscovery] = useState<"idle" | "loading" | "ready" | "error">("idle");
150152
const [buildDiscovery, setBuildDiscovery] = useState<"idle" | "loading" | "ready" | "error">("idle");
151153
const [discoveryRetry, setDiscoveryRetry] = useState(0);
152-
const [runtime, setRuntime] = useState<Partial<SandboxRuntimeRelease>>({});
154+
const [runtime, setRuntime] = useState<Partial<SandboxRuntimeRelease>>(proposedRuntime ?? {});
153155
const [busy, setBusy] = useState(false);
154156
const [dockerConfirmed, setDockerConfirmed] = useState(current?.provider === "docker");
155157
const [confirmingDocker, setConfirmingDocker] = useState(false);
@@ -193,9 +195,9 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
193195
setTemplate(""); setSelectedTemplate(""); setTemplates([]); setBuilds([]);
194196
}
195197

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

201203
const needsRuntime = policy?.runtime ?? false;

‎apps/web/src/features/sandbox/sandbox-queries.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { queryOptions, useQuery } from "@tanstack/react-query";
33

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

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

28+
/** The Runtime release of this console's distribution manifest, the release its node installer verifies; null when it serves none. */
29+
export const distributionRuntimeQuery = queryOptions({
30+
queryKey: ["sandbox-runtime-release"],
31+
queryFn: ({ signal }) => distributionRuntime(signal).catch(() => null),
32+
staleTime: Infinity,
33+
retry: false,
34+
});
35+
2736
/** The deployment alone, for pages that only describe it. */
2837
export const sandboxDeploymentQuery = queryOptions<SandboxDeployment>({
2938
queryKey: [...sandboxScope, "deployment"],

‎apps/web/src/features/settings/settings.css‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@
105105
overflow-wrap: anywhere;
106106
}
107107

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

111111
.settings-row pre {

‎apps/web/src/i18n/locales/zh-CN/sandbox.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,9 @@ export const sandbox = {
331331
"Root disk (MiB)": "根盘(MiB)",
332332
"Root disk {{root}} · data disk {{data}}": "根盘 {{root}} · 数据盘 {{data}}",
333333
"Runtime": "Runtime",
334+
"Saved <saved/> · this installation serves <served/>": "已保存 <saved/> · 本安装提供 <served/>",
335+
"Other changes keep the saved Runtime. Existing sandboxes keep their Runtime while nodes prepare the new one.": "其他修改会保留已保存的 Runtime。节点准备新 Runtime 期间,现有沙箱继续使用原来的 Runtime。",
336+
"Use this installation's Runtime": "使用本安装的 Runtime",
334337
"Runtime SHA-256": "Runtime SHA-256",
335338
"Runtime release": "Runtime 版本",
336339
"Runtime release needed": "需要 Runtime 版本",

0 commit comments

Comments
 (0)