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: 2 additions & 0 deletions apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,3 +474,5 @@ Motion reports state and never makes anyone wait: the navigation chip and segmen
Use Lucide throughout. Navigation icons are 18px with a 1.75px stroke; shared tool icons use the same stroke at their existing compact sizes. Default navigation icons use secondary ink, selected icons use primary ink. The selected row has a neutral background and a narrow indigo marker.

Getting started emphasizes the first confirmed incomplete step with the primary button while leaving every step independently available. Only authoritative, successful zero-Session reads replace the activity chart with first-use guidance. Missing and failed reads retain their existing uncertainty and recovery controls.

Configuration controls show concise labels, values and actions. Put necessary explanatory policy text behind a `HelpTip` question mark; do not add persistent small-print helper text or note paragraphs. Validation and request failures remain visible beside the affected action or field.
4 changes: 2 additions & 2 deletions apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@ The console runs beside the administrator's own Core, with execution, files and
- **Online sandbox configuration** changes the same backend's resources, Runtime or E2B template without retiring existing nodes or changing existing Sessions' resource ownership. New placement follows Core's qualified capacity; saving a target does not promise immediate placement on it. Configuration rollout shows Core's target preparation and retained previous-generation sandbox count. A settled rollout can still have failed, update-required or unknown nodes and old resources. An offline node stays offline even when it has a recorded serving generation. Node and allocation detail distinguish the serving pin, target preparation and each resource's configuration generation.
- **E2B credential replacement** uses the same configuration form. Setup requires a key; leaving it blank during an update keeps the saved key. An explicit key, even the same value, is verified as a replacement and advances the target generation after successful verification. Another backend or E2B team requires a deliberate reset. A rejected or uncertain replacement never clears the committed configuration or replays the write.
- **E2B deployments** have no machines: Nodes offers a link to System's sandbox configuration. Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud (running, starting, size, template build) instead of node capacity, with no node column or Add node action; a sandbox's dialog adds its disk use.
- **Automatic suspension** appears when the deployment declares a suspension policy. Nodes and Sandbox metrics show Core's active/retained placement counts and limits across online nodes, suspended placements across all registered nodes, and the read-only idle and snapshot-retention policy. 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 retains Runtime observation status separately from the matching allocation's recorded phase. 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.
- **Automatic suspension** appears when the deployment declares a suspension policy. Nodes and Sandbox metrics show Core's active/retained placement counts and limits across online nodes, suspended placements across all registered nodes, and the current idle and snapshot-retention policy. Nodes owns one **Capacity and suspension** area: deployment-wide idle seconds are editable there alongside per-node active and retained limits; 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 retains Runtime observation status separately from the matching allocation's recorded phase. 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.
- **Getting started**: signing in opens the console on the Overview; nothing is forced first. While a step is to do, a Getting started checklist on the Overview shows four steps, in any order, each with its state and one action: sandboxes ready (a saved deployment and a node online and ready, or a saved E2B deployment whose template build is not reported as not ready), a default model provider on the default harness (on any enabled harness when none is default), a project with an active key, and a first Session, whose action opens the call samples of the newest active project, preferring one with an active key. Completion comes from reads the console already makes. It can be hidden; Show Getting started in the sidebar opens it again, and it ends with a brief "You're set". While it is open, Add node ends with the next step once its node is ready: the default model provider while that is to do, otherwise back to the checklist. The optional three-chapter tour of the console (Monitor, Resources, Platform) opens from it, on the sign-in stage.
- Terminology: API terms stay in English in the Chinese UI (Agent, Session, Turn, Skill, Vault, Credential, API key). The sign-in credential is the Core key ("Core Key"); keys issued in a project for applications are project API keys ("项目 API Key"). Provider readiness is "Provider not ready / 提供方未就绪"; revoked credentials and keys use "Revoked / 已撤销". A default model provider is a service address and write-only key; the application's Agent `model` chooses the provider-supported model name. A sandbox is Core-managed compute; Runtime names Core's execution observations, and Environment is the Session's API execution environment. These are distinct counts and resources, not synonyms.

## Capabilities and Constraints

- **Projects and keys.** A project owns an isolated set of assets shared by all of its named API keys; projects do not see each other's assets. Issuing or revoking a key never touches assets. Archiving a project revokes every key and keeps its assets viewable and deletable; it can't be undone, so its confirmation says so, counts the active keys it revokes and, when there are any, asks for the project's name. Key plaintext is shown once, at issuance, and never stored by the console. Beside it, and without the key on an active project's 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.
- **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 capacity and automatic suspension policy, 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 a project's keys through the Agents API. The console does not create or edit Agents or Templates, upload Skills or Files, create or replace Credentials, start Sessions, send input or cancel work. Deletion follows the public deletion rules; a busy Session is not deletable and the console never cancels work to make it so.
- **Secrets stay write-only.** Credential tokens, Template environment variables and setup commands are never returned, to the administrator included. An Agent's saved model provider shows its protocol, base URL, limits and whether a key is configured, never the key.
Expand Down
7 changes: 7 additions & 0 deletions apps/web/e2e/fixture-console.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,13 @@ async function sandboxRoute(request, response, path, url) {
const data = state.admin.runtimeObservations().map((entry) => ({ ...entry, observation: { ...entry.observation, disk: e2b && entry.observation.status === "observed" ? { usage_bytes: 3 * 2 ** 30, limit_bytes: 10 * 2 ** 30 } : null } }));
return send(response, 200, { object: "list", data, has_more: false, first_id: data[0]?.observation.id ?? null, last_id: data.at(-1)?.observation.id ?? null });
}
if (path === "/deployment" && request.method === "PATCH") {
const input = await body(request);
if (!state.deployment.suspension || state.deployment.reset) return error(response, 409, "Suspension policy is unavailable.", "sandbox_deployment_conflict");
if (!Number.isInteger(input.idle_seconds) || input.idle_seconds < 30 || input.idle_seconds > 86400) return error(response, 400, "Invalid idle seconds.", "invalid_request_error");
state.deployment.suspension.idle_seconds = input.idle_seconds;
return send(response, 200, state.deployment);
}
if (path === "/deployment" && (request.method === "POST" || request.method === "PUT")) {
const input = await body(request);
const initialize = request.method === "POST";
Expand Down
11 changes: 5 additions & 6 deletions apps/web/e2e/nodes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,18 +352,17 @@ test("keeps the page usable when Core refuses a sandbox change, and shows a safe
});

test("renames a node and sets how many sandboxes run on it at once", async ({ page, request }) => {
await openConsole(page, request, "nodes?id=node-local");
// A Docker node shows its limit too, and the edit shows what the host holds.
const capacity = page.getByRole("region", { name: "Capacity" });
await openConsole(page, request, "nodes");
// The Nodes policy area owns all per-node capacity edits.
const capacity = page.getByRole("region", { name: "Capacity and suspension" });
await expect(capacity).toContainText("Active / limit");
await expect(capacity).toContainText("5 / 8");
await page.getByRole("button", { name: "Edit node" }).click();
await capacity.getByRole("row").filter({ hasText: "core-01" }).getByRole("button", { name: "Edit node" }).click();
const edit = page.getByRole("dialog", { name: "Edit node" });
await expect(edit.getByText("Host: 16 CPU · 64 GiB. Each sandbox: 2 CPU · 4 GiB. Suggested: at most 8 at once.")).toBeVisible();
await edit.getByLabel("Name").fill("core-01-large");
await edit.getByLabel("Sandboxes at once").fill("6");
await edit.getByRole("button", { name: "Save" }).click();
await expect(edit).toBeHidden();
await expect(page.getByRole("heading", { name: "core-01-large", level: 1 })).toBeVisible();
await expect(capacity).toContainText("core-01-large");
await expect(capacity).toContainText("5 / 6");
});
30 changes: 27 additions & 3 deletions apps/web/e2e/sandbox-lifecycle.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test";
import { openConsole, expectManagementBoundary } from "./console";
import { openConsole, expectManagementBoundary, setDeployment, writes } from "./console";

test.afterEach(async ({ request }) => expectManagementBoundary(request));

Expand All @@ -15,9 +15,9 @@ test("shows automatic suspension policy, retained capacity and elapsed phase tim
await route.fulfill({ response, json: { data: data.map((node: Record<string, unknown>) => ({ ...node, provider: "microsandbox", retained: Number(node.active) + 2 })) } });
});
await openConsole(page, request, "nodes");
await expect(page.getByRole("heading", { name: "Automatic suspension" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Capacity and suspension" })).toBeVisible();
await expect(page.getByText("5 minutes", { exact: true })).toBeVisible();
await expect(page.getByRole("columnheader", { name: "Retained / limit" })).toBeVisible();
await expect(page.getByRole("region", { name: "Capacity and suspension" }).getByRole("columnheader", { name: "Retained / limit" })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("nodes-en.png"), fullPage: true });
await page.getByRole("button", { name: "Open core-01", exact: true }).click();
const allocations = page.getByRole("table", { name: "Sandbox allocations" });
Expand All @@ -41,3 +41,27 @@ test("hides suspension policy when the provider declares no suspension support",
await expect(page.getByRole("heading", { name: "Automatic suspension" })).toHaveCount(0);
await expect(page.getByRole("columnheader", { name: "Retained / limit" })).toHaveCount(0);
});


test("edits deployment idle time beside node limits without changing generation", async ({ page, request }) => {
await openConsole(page, request, "nodes");
const deployment = await setDeployment(request, { suspension: { idle_seconds: 300, retention_seconds: 86400 } });
await setDeployment(request, { provider: "microsandbox", specification: { ...deployment.specification!, resources: { ...deployment.specification!.resources, root_disk_mib: 8192, environment_disk_mib: 8192 } } });
await page.getByRole("button", { name: "Refresh sandbox state" }).click();
const area = page.getByRole("region", { name: "Capacity and suspension" });
const idle = area.getByLabel("Idle before suspension (seconds)");
await expect(idle).toHaveValue("300");
await idle.fill("29");
await expect(area.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
await expect(area.getByText("Enter a whole number from 30 to 86,400.")).toBeVisible();
await idle.fill("60");
const response = page.waitForResponse((response) => response.request().method() === "PATCH" && response.url().endsWith("/sandbox/deployment"));
await area.getByRole("button", { name: "Save", exact: true }).click();
const saved = await (await response).json();
expect(saved.generation).toBe(deployment.generation);
expect(saved.suspension).toEqual({ idle_seconds: 60, retention_seconds: 86400 });
await expect(idle).toHaveValue("60");
await page.reload();
await expect(idle).toHaveValue("60");
expect((await writes(request)).filter((write) => write === "PATCH /core/v1/sandbox/deployment")).toHaveLength(1);
});
5 changes: 2 additions & 3 deletions apps/web/src/features/sandbox/NodeEditDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,9 @@ export function NodeEditDialog({ client, node, size, suspends, onClose, onSaved
{nameError ? <span id={`${id}-name-error`} className="field-error">{nameError}</span> : null}
</label>
<div className="field">
<span className="field-label-row"><label htmlFor={`${id}-active`}>{t("Sandboxes at once")}</label><HelpTip>{t("The most sandboxes Core places on this node at the same time.")}</HelpTip></span>
<input id={`${id}-active`} inputMode="numeric" value={active} onChange={(event) => { setActive(event.target.value); setError(null); }} aria-invalid={Boolean(activeError)} aria-errormessage={activeError ? `${id}-active-error` : undefined} aria-describedby={hostFacts ? `${id}-host` : undefined} />
<span className="field-label-row"><label htmlFor={`${id}-active`}>{t("Sandboxes at once")}</label><HelpTip>{t("The most sandboxes Core places on this node at the same time.")}{hostFacts ? <> {hostFacts}</> : null}</HelpTip></span>
<input id={`${id}-active`} inputMode="numeric" value={active} onChange={(event) => { setActive(event.target.value); setError(null); }} aria-invalid={Boolean(activeError)} aria-errormessage={activeError ? `${id}-active-error` : undefined} />
{activeError ? <span id={`${id}-active-error`} className="field-error">{activeError}</span> : null}
{hostFacts ? <small id={`${id}-host`}>{hostFacts}</small> : null}
</div>
{suspends ? (
<div className="field">
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/features/sandbox/NodeList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function OldAddressHint() {
return <span className="node-status-hint">{t("Remove and add again")}</span>;
}

export function NodeList({ nodes, allocations, coreUrl, stale, disabled, suspends = false, onOpen, onRemove }: {
export function NodeList({ nodes, allocations, coreUrl, stale, disabled, suspends = false, onOpen, onRemove, onEdit }: {
nodes: readonly SandboxNode[];
/** The deployment's address; a node enrolled with another one is on an old address. */
coreUrl: string;
Expand All @@ -67,6 +67,7 @@ export function NodeList({ nodes, allocations, coreUrl, stale, disabled, suspend
disabled: boolean;
onOpen: (node: SandboxNode) => void;
onRemove: (node: SandboxNode) => void;
onEdit?: (node: SandboxNode) => void;
}) {
const { t, i18n } = useTranslation("sandbox");
const locale = i18n.resolvedLanguage;
Expand Down Expand Up @@ -113,6 +114,7 @@ export function NodeList({ nodes, allocations, coreUrl, stale, disabled, suspend
<td className="nodes-nowrap">{formatDateTime(epochSeconds(node.created_at), locale)}</td>
<td className="actions-cell">
<RowActions>
{onEdit ? <button className="text-action" type="button" disabled={disabled} onClick={() => onEdit(node)}>{t("Edit node")}</button> : null}
<button className="text-action danger" type="button" disabled={disabled} aria-label={t("Remove {{name}}", { name })} onClick={() => onRemove(node)}>
{t("Remove")}
</button>
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/features/sandbox/SandboxLifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ describe("sandbox lifecycle evidence", () => {
expect(html).toContain("5 minutes");
expect(html).not.toContain("3 / 14");
});
it("offers policy editing only when the owner supplies controls", () => {
const nodes = [node("online", { active: 2, max_active: 4, retained: 5, max_retained: 8 })];
const html = renderToStaticMarkup(<SandboxLifecycle deployment={deployment} nodes={nodes} stale={false} controls={{ disabled: false, onSave: async () => true }} />);
expect(html).toContain("Capacity and suspension");
expect(html).toContain('id="sandbox-idle-seconds"');
expect(html).toContain('value="300"');
const readonly = renderToStaticMarkup(<SandboxLifecycle deployment={deployment} nodes={nodes} stale={false} />);
expect(readonly).not.toContain("sandbox-idle-seconds");
expect(readonly).not.toContain("Edit node");
const unsupported = renderToStaticMarkup(<SandboxLifecycle deployment={{ ...deployment, suspension: null }} nodes={nodes} stale={false} controls={{ disabled: true, onSave: async () => true }} />);
expect(unsupported).not.toContain("sandbox-idle-seconds");
});
it("does not replace missing inventory with zero or claim fresh evidence after failure", () => {
const html = renderToStaticMarkup(<SandboxLifecycle deployment={deployment} nodes={null} stale />);
expect(html).toContain("current state is unconfirmed");
Expand Down
Loading
Loading