From 4bd3011b5dba23f455a04ac56462265b028eb645 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 17:29:50 +0800 Subject: [PATCH 01/10] feat: checkpoint hosted sandbox manager implementation --- CONTRIBUTING.md | 75 ++- Makefile | 8 +- README.md | 9 +- apps/web/e2e/fixture-core.mjs | 4 + apps/web/e2e/fixture-sandbox.mjs | 43 ++ apps/web/e2e/sandbox-manager.spec.ts | 152 +++++ apps/web/src/App.tsx | 31 +- apps/web/src/components/SystemNavigation.tsx | 12 + apps/web/src/features/sandbox/NodeHealth.tsx | 14 + .../src/features/sandbox/SandboxContext.tsx | 18 + .../features/sandbox/SandboxDiagnostic.tsx | 9 + .../features/sandbox/SandboxManagerView.css | 23 + .../features/sandbox/SandboxManagerView.tsx | 112 ++++ .../features/sandbox/SandboxNodeSelector.tsx | 37 ++ .../src/features/sandbox/SessionPlacement.tsx | 31 + .../sandbox/enrollment-command.test.ts | 23 + .../features/sandbox/enrollment-command.ts | 14 + .../sessions/actions/SessionActionsDialog.tsx | 2 + .../sessions/create/SessionStartDialog.tsx | 10 +- .../create/session-create-attempt.test.ts | 10 + .../sessions/create/session-create-attempt.ts | 2 + apps/web/src/lib/sandbox-diagnostic.test.ts | 23 + apps/web/src/lib/sandbox-diagnostic.ts | 32 + apps/web/vite.config.ts | 1 + contracts/agents-api/openapi.yaml | 114 ++++ .../agents-api/sandbox-manager.openapi.yaml | 558 ++++++++++++++++++ contracts/agents-api/v1/model_execution.go | 3 +- packages/agents-client/src/client.ts | 2 +- packages/agents-client/src/index.ts | 1 + .../agents-client/src/sandbox-client.test.ts | 40 ++ packages/agents-client/src/sandbox-client.ts | 80 +++ packages/agents-client/src/types.ts | 1 + scripts/build-agents-api-release.sh | 2 +- scripts/build-agents-api.sh | 5 +- scripts/openapi-split/main.go | 182 ++++++ scripts/openapi-split/main_test.go | 70 +++ services/agents-api/Dockerfile | 4 +- services/agents-api/HOSTED-SANDBOX-MANAGER.md | 169 ++++++ services/agents-api/README.md | 6 + services/agents-api/RELEASE.md | 6 + services/agents-api/cmd/sandbox-node/main.go | 135 +++++ services/agents-api/cmd/server/main.go | 58 +- .../cmd/server/managed_microsandbox.go | 71 +-- .../agents-api/cmd/server/managed_nodes.go | 166 ++++++ .../agents-api/cmd/server/managed_runtimes.go | 131 +--- .../cmd/server/startup_configuration_test.go | 11 + services/agents-api/internal/api/errors.go | 9 + services/agents-api/internal/api/handler.go | 28 +- .../internal/api/sandbox_manager.go | 237 ++++++++ .../internal/api/sandbox_manager_auth.go | 52 ++ .../internal/api/sandbox_manager_test.go | 73 +++ .../internal/api/sandbox_placement.go | 73 +++ .../internal/api/sandbox_selector_test.go | 55 ++ .../db/queries/initial_environment_files.sql | 4 +- .../db/queries/runtime_allocations.sql | 12 +- .../internal/db/queries/runtime_nodes.sql | 97 +++ .../db/queries/runtime_suspension.sql | 18 +- .../db/sqlc/initial_environment_files.sql.go | 12 +- .../agents-api/internal/db/sqlc/models.go | 37 ++ .../db/sqlc/runtime_allocations.sql.go | 40 +- .../db/sqlc/runtime_deployment.sql.go | 5 +- .../internal/db/sqlc/runtime_nodes.sql.go | 556 +++++++++++++++++ .../db/sqlc/runtime_suspension.sql.go | 36 +- .../internal/execution/runtime_compute.go | 18 +- .../internal/execution/runtime_lifecycle.go | 35 +- .../internal/execution/runtime_observation.go | 36 ++ .../agents-api/internal/execution/worker.go | 2 +- .../internal/sandbox/config/config.go | 187 ++++++ .../internal/sandbox/config/config_test.go | 75 +++ .../internal/sandbox/config/microsandbox.go | 70 +++ .../internal/sandbox/config/probe.go | 59 ++ .../internal/sandbox/microsandbox/provider.go | 38 +- .../sandbox/microsandbox/provider_test.go | 6 +- .../agents-api/internal/sandbox/node/agent.go | 303 ++++++++++ .../internal/sandbox/node/docker_live_test.go | 154 +++++ .../internal/sandbox/node/enrollment.go | 91 +++ .../internal/sandbox/node/health_linux.go | 36 ++ .../internal/sandbox/node/health_other.go | 5 + .../agents-api/internal/sandbox/node/hub.go | 288 +++++++++ .../internal/sandbox/node/identity.go | 166 ++++++ .../internal/sandbox/node/node_test.go | 284 +++++++++ .../agents-api/internal/sandbox/node/proxy.go | 126 ++++ .../internal/sandbox/node/recovery_test.go | 276 +++++++++ .../internal/sandbox/node/timeout_test.go | 133 +++++ .../agents-api/internal/sandbox/node/wire.go | 323 ++++++++++ .../agents-api/internal/sandbox/suspension.go | 4 +- .../store/runtime_allocation_state.go | 11 +- .../internal/store/runtime_allocations.go | 20 +- .../store/runtime_compute_lifecycle_test.go | 8 +- .../internal/store/runtime_deployment.go | 22 +- .../internal/store/runtime_idle_clock_test.go | 155 +++++ .../internal/store/runtime_node_deployment.go | 81 +++ .../internal/store/runtime_node_types.go | 86 +++ .../internal/store/runtime_nodes.go | 292 +++++++++ .../internal/store/runtime_nodes_test.go | 374 ++++++++++++ .../internal/store/runtime_observation.go | 48 ++ .../store/runtime_observation_test.go | 79 +++ .../internal/store/runtime_placements.go | 104 ++++ .../internal/store/runtime_suspension.go | 5 + .../internal/store/session_deletion.go | 3 + .../internal/store/session_events.go | 4 + .../internal/store/session_initial_input.go | 15 +- .../agents-api/internal/store/sessions.go | 6 +- .../store/subagent_turn_projection.go | 6 + .../internal/store/turn_completion.go | 11 +- .../migrations/000056_runtime_nodes.sql | 55 ++ 106 files changed, 7659 insertions(+), 295 deletions(-) create mode 100644 apps/web/e2e/fixture-sandbox.mjs create mode 100644 apps/web/e2e/sandbox-manager.spec.ts create mode 100644 apps/web/src/components/SystemNavigation.tsx create mode 100644 apps/web/src/features/sandbox/NodeHealth.tsx create mode 100644 apps/web/src/features/sandbox/SandboxContext.tsx create mode 100644 apps/web/src/features/sandbox/SandboxDiagnostic.tsx create mode 100644 apps/web/src/features/sandbox/SandboxManagerView.css create mode 100644 apps/web/src/features/sandbox/SandboxManagerView.tsx create mode 100644 apps/web/src/features/sandbox/SandboxNodeSelector.tsx create mode 100644 apps/web/src/features/sandbox/SessionPlacement.tsx create mode 100644 apps/web/src/features/sandbox/enrollment-command.test.ts create mode 100644 apps/web/src/features/sandbox/enrollment-command.ts create mode 100644 apps/web/src/lib/sandbox-diagnostic.test.ts create mode 100644 apps/web/src/lib/sandbox-diagnostic.ts create mode 100644 contracts/agents-api/sandbox-manager.openapi.yaml create mode 100644 packages/agents-client/src/sandbox-client.test.ts create mode 100644 packages/agents-client/src/sandbox-client.ts create mode 100644 scripts/openapi-split/main.go create mode 100644 scripts/openapi-split/main_test.go create mode 100644 services/agents-api/HOSTED-SANDBOX-MANAGER.md create mode 100644 services/agents-api/cmd/sandbox-node/main.go create mode 100644 services/agents-api/cmd/server/managed_nodes.go create mode 100644 services/agents-api/internal/api/sandbox_manager.go create mode 100644 services/agents-api/internal/api/sandbox_manager_auth.go create mode 100644 services/agents-api/internal/api/sandbox_manager_test.go create mode 100644 services/agents-api/internal/api/sandbox_placement.go create mode 100644 services/agents-api/internal/api/sandbox_selector_test.go create mode 100644 services/agents-api/internal/db/queries/runtime_nodes.sql create mode 100644 services/agents-api/internal/db/sqlc/runtime_nodes.sql.go create mode 100644 services/agents-api/internal/execution/runtime_observation.go create mode 100644 services/agents-api/internal/sandbox/config/config.go create mode 100644 services/agents-api/internal/sandbox/config/config_test.go create mode 100644 services/agents-api/internal/sandbox/config/microsandbox.go create mode 100644 services/agents-api/internal/sandbox/config/probe.go create mode 100644 services/agents-api/internal/sandbox/node/agent.go create mode 100644 services/agents-api/internal/sandbox/node/docker_live_test.go create mode 100644 services/agents-api/internal/sandbox/node/enrollment.go create mode 100644 services/agents-api/internal/sandbox/node/health_linux.go create mode 100644 services/agents-api/internal/sandbox/node/health_other.go create mode 100644 services/agents-api/internal/sandbox/node/hub.go create mode 100644 services/agents-api/internal/sandbox/node/identity.go create mode 100644 services/agents-api/internal/sandbox/node/node_test.go create mode 100644 services/agents-api/internal/sandbox/node/proxy.go create mode 100644 services/agents-api/internal/sandbox/node/recovery_test.go create mode 100644 services/agents-api/internal/sandbox/node/timeout_test.go create mode 100644 services/agents-api/internal/sandbox/node/wire.go create mode 100644 services/agents-api/internal/store/runtime_idle_clock_test.go create mode 100644 services/agents-api/internal/store/runtime_node_deployment.go create mode 100644 services/agents-api/internal/store/runtime_node_types.go create mode 100644 services/agents-api/internal/store/runtime_nodes.go create mode 100644 services/agents-api/internal/store/runtime_nodes_test.go create mode 100644 services/agents-api/internal/store/runtime_observation.go create mode 100644 services/agents-api/internal/store/runtime_observation_test.go create mode 100644 services/agents-api/internal/store/runtime_placements.go create mode 100644 services/agents-api/migrations/000056_runtime_nodes.sql diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db71ce333..0bdfdd210 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,7 +113,9 @@ migrations. The public protocol schema is `contracts/agents-api/openapi.yaml`; there is no product swaggo contract in this repository. Preserve its pinned types, coverage ledgers and official SDK/raw HTTP tests when changing API behavior. Run `make openapi` after handler annotation changes. It reuses the original -Core-only swaggo v1.16.4 generator and writes this schema, without product routes. +Core-only swaggo v1.16.4 generator, then separates project paths under `/v1` from +`/core/v1/sandbox` administration in `sandbox-manager.openapi.yaml` (base path `/`). +Both generated schemas remain free of product routes. Core changes must retain the independent build and official-client workflow. Native adapter changes require their applicable build/check targets and live provider @@ -519,7 +521,7 @@ See the [Template coverage and unresolved semantics](contracts/agents-api/enviro SandboxProvider has five operations: Create, GetInfo, Renew, Kill and RunCommand. Use maintained provider SDKs and thin adapters. Hosted deployments select Docker or -the optional single-host microsandbox profile. +the optional microsandbox profile on the assigned node. Provider initialization creates the sandbox and starts its daemon/harness; RunCommand is for initialization only. Daily execution and Files use Runtime and native or bounded local capabilities. Docker's lack of a native renewable lease @@ -558,13 +560,13 @@ The [managed Runtime build and operator configuration](services/agents-api/deplo defines the explicit opt-in for basic hosted admission. Building an image alone does not qualify its isolation or enable public creation. -### Optional single-host sandbox suspension +### Hosted sandbox nodes and optional suspension Each Core deployment enables exactly one sandbox provider, selected at setup: Docker or microsandbox. Keep both adapters but reject multiple provider entries, legacy default-provider maps and engine-based placement. Harness selection is -independent. The configuration has one installation UUID, one provider kind and -one backend object. No compatibility parser or parallel provider route remains. +independent. The configuration has one installation UUID and one provider kind. +A local node has one explicit backend object; a remote-only Core has none. No mixed-provider or engine-based provider route is supported. The execution database pins the selected installation and backend namespace. Under the existing execution lease, startup validates that identity before @@ -581,6 +583,51 @@ admit new sandboxes. Retain immutable historical allocation ownership; never migrate an existing Session to another provider or recreate a released allocation. Fresh adoption of a deployment with unverified retained allocations fails closed. +The [Hosted Sandbox Manager](services/agents-api/HOSTED-SANDBOX-MANAGER.md) is a +deployment-level admin surface, separate from project credentials. Its Web token +stays in memory. Node enrollment credentials authorize only registration; durable +node credentials authorize only node transport. Project keys can read a narrow +node directory and their own Session placement, never global allocations. + +One execution owner manages local and remote nodes through the same finite +Provider protocol. The embedded local node preserves existing single-host setup; +remote nodes actively connect over authenticated TLS. Persist private node +identity and highest owner epoch; refuse another process using the same identity +or a changed backend namespace. Heartbeats establish provider readiness and +last-observed host metrics, never Session activity. Transport reconnects use +bounded backoff. Send relative operation budgets, anchored to the node clock at +receipt and consumed while queued; clocks on different hosts need not agree. Core +still bounds its own response wait. Do not replay mutations after a timeout or +lost response. Retain allocation +and checkpoint operation receipts and observe the original operation instead. +Disconnects and read timeouts are unavailable/uncertain, never resource absence. +Online-state writes compare the handshake epoch atomically in PostgreSQL so a +stale Core cannot publish readiness for a new owner. Node-managed allocations +do not expire merely because the internal observation keepalive is an hour old; +explicit deletion and configured snapshot retention still authorize cleanup. +Persist only bounded, sanitized observation codes for offline, missing or +unconfirmed resources; keep these separate from the lifecycle and do not invent +a successful running observation after a host restart. + +Commit environment-to-node placement with Session creation and its creation retry +identity. Automatic selection chooses an eligible node; explicit +`x_agents_core.sandbox_node_id` fails if unavailable or full. The optional +model-provider extension remains independent. Existing retries keep their original +node even when it is offline. Node capacity counts pending reservations and +unresolved resources; new placement and suspended-to-restoring admission share a +database lock. Confirmed cleanup releases placement capacity. Under the existing +execution lease, adopt old single-host resources only after the installation and +backend fingerprint match, retaining them and pending Environments on the local +node. Never infer a host from an identical socket path on another machine. + +Do not add node-level drain controls. Refuse node removal with pending allocations, +instances, snapshots, unknown results or cleanup resources. Offline ownership is +retained. Removing a node does not delete compute. Refuse deletion of the embedded local +node while deployment configuration still enables it; changing that configuration +requires the existing clean maintenance transition. Keep the deployment-wide +maintenance/provider-switch guard. This boundary does not add cross-node Session +migration, Core multi-active, autoscaling, Kubernetes or harness residency. + The common `services/agents-api/internal/sandbox` contract owns the five base operations (Create, GetInfo, Renew, Kill, RunCommand) and the optional CheckpointProvider @@ -601,9 +648,13 @@ restore. Do not inherit undeclared host resources. Suspend only after at least one Turn is terminal, no queued/in-progress/waiting root or subagent Turn, pending input/file operation or initialization remains, -and real activity has been idle for the configured interval. Heartbeats do not -reset activity. The daemon must close admission and drain native cleanup, output -receipts and file work before acknowledging planned suspension. Never change a +and real activity has been idle for the configured interval. For node-managed +allocations, record the first root or child terminal transition in the same +transaction using Core's database clock and the existing compute activity field. +Native completion timestamps remain unchanged in public history but cannot drive +idle admission across hosts; repeated terminal projections never reset that timer. +Heartbeats do not reset activity. The daemon must close admission and drain native +cleanup, output receipts and file work before acknowledging planned suspension. Never change a harness or keep an agent process alive across Turns solely to meet this feature. The acceptance boundary is a next Turn in the same Session with history, files and configuration intact, without replaying an earlier request. @@ -624,7 +675,7 @@ precedence over wake, including at the final database compare-and-swap. Retain unknown cleanup identities until owned resources are confirmed absent. Fixed guest CPU/memory/disk settings, max_active reservations, max_retained -allocation count and snapshot retention bound the single host. Unknown operations +allocation count and snapshot retention bound each assigned node. Unknown operations retain capacity reservations. Source teardown must be confirmed before releasing active capacity. Delete consumed artifacts and old compute closures; do not grow a chain of old writable disks across suspension cycles. No Kubernetes, distributed @@ -2716,9 +2767,9 @@ Effective extension reads use the persisted engine; Sessions without the extensi retain the official Agent response shape. See the [extension contract](contracts/agents-api/harness-selection.md) for null/retry behavior and operator configuration. -Hosted engine-to-provider selection belongs to Core composition. Admission and -initial allocation share the mapping; retained allocations use their persisted -provider identity. Runtime images must satisfy their existing qualification rules. +Hosted provider selection belongs to deployment configuration and is independent +of the engine. Session creation fixes a node through a Core extension or automatic +placement; retained allocations keep that node and provider identity. Runtime images must satisfy their existing qualification rules. Transient model options are partitioned by engine and must not expose another engine's credentials. Do not infer an engine from a model name or template. diff --git a/Makefile b/Makefile index f9142df47..422e4d548 100644 --- a/Makefile +++ b/Makefile @@ -17,21 +17,23 @@ check-database: sqlc-generate: cd services/agents-api && $(SQLC) generate +SWAG ?= go run github.com/swaggo/swag/cmd/swag@$(SWAG_VERSION) + .PHONY: openapi openapi: @set -e; root="$${PARSAR_HOME:-$$HOME/.parsar}/build"; mkdir -p "$$root"; \ output=$$(mktemp -d "$$root/core-openapi.XXXXXX"); trap 'rm -rf "$$output"' EXIT; \ - go run github.com/swaggo/swag/cmd/swag@$(SWAG_VERSION) init \ + $(SWAG) init \ -g cmd/server/main.go --dir ./services/agents-api,./contracts/agents-api/v1 \ --output "$$output" \ --outputTypes yaml --parseInternal; \ - mv "$$output/swagger.yaml" contracts/agents-api/openapi.yaml + go run ./scripts/openapi-split "$$output/swagger.yaml" contracts/agents-api/openapi.yaml contracts/agents-api/sandbox-manager.openapi.yaml check-sqlc: python3 scripts/check-sqlc.py check-go: - go test ./apps/parsar-daemon/... ./internal/... ./contracts/agents-api/... -count=1 + go test ./apps/parsar-daemon/... ./internal/... ./contracts/agents-api/... ./scripts/openapi-split -count=1 build-daemon: @set -e; output="$${PARSAR_HOME:-$$HOME/.parsar}/build/daemon"; \ diff --git a/README.md b/README.md index cc80ee81a..f6c6992d2 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,12 @@ The source repository retains both its product and its existing Core copy. This repository contains the API service, PostgreSQL migrations, pinned public protocol, execution daemon, the Docker provider, native Harness adapters, runtime image builders, Go and TypeScript client libraries, the standalone Core -Web console, tests and operator documentation. It does not contain the Parsar +Web console, node service, tests and operator documentation. It does not contain the Parsar product application, product backend, product database, business CLI or product deployment stack. V1 user-managed deployments colocate our daemon, selected harness, tools and -`/workspace`. Core manages Docker only; users provision, renew and destroy E2B +`/workspace`. Core manages one selected Docker or microsandbox provider across its registered nodes; users provision, renew and destroy E2B through the official SDK. The returned `remote_url` uses our private daemon transport, not stock `exec-server`. See the [Runtime enrollment guide](services/agents-api/README.md#user-managed-runtime-enrollment) @@ -24,6 +24,7 @@ for tested deployments and remaining limits. - [API setup, authentication and execution](services/agents-api/README.md) - [Standalone containers](services/agents-api/CONTAINER.md) +- [Hosted Sandbox Manager](services/agents-api/HOSTED-SANDBOX-MANAGER.md) - [Docker Runtime](services/agents-api/deploy/codex/README.md) - [Protocol coverage and known gaps](contracts/agents-api/README.md) - [Harness selection](contracts/agents-api/harness-selection.md) @@ -45,8 +46,8 @@ Provision a dedicated Core PostgreSQL database and caller credentials using the operator guide before starting the service. Native execution also needs the appropriate Runtime image and provider configuration. -Core Web lives in `apps/web` and talks only to the public `/v1/agents/**` -HTTP/SSE contract through the TypeScript implementation in +Core Web lives in `apps/web` and uses the public `/v1/agents/**` HTTP/SSE contract +and explicit Core sandbox-management extensions through the TypeScript implementation in `packages/agents-client`. The Go client remains in `packages/agents-client/v1`; both clients live next to the contract they consume without coupling browser state to Core execution internals. diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index 127487337..dfd7f93e7 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -1,4 +1,5 @@ import http from "node:http"; +import { handleSandboxFixture, resetSandboxFixture } from "./fixture-sandbox.mjs"; const host = "127.0.0.1"; const port = Number(process.env.AGENTS_FIXTURE_PORT ?? 18092); @@ -758,6 +759,8 @@ const server = http.createServer(async (request, response) => { try { const url = new URL(request.url ?? "/", `http://${host}:${port}`); + if (handleSandboxFixture(request, response, url, sendJson, sendError)) return; + if (request.method === "GET" && url.pathname === "/__fixture/health") { return sendJson(response, { ready: true }); } @@ -765,6 +768,7 @@ const server = http.createServer(async (request, response) => { for (const stream of streamResponses.keys()) stream.end(); streamResponses.clear(); state = initialState(); + resetSandboxFixture(); return sendJson(response, { reset: true }); } if (request.method === "POST" && url.pathname === "/__fixture/control") { diff --git a/apps/web/e2e/fixture-sandbox.mjs b/apps/web/e2e/fixture-sandbox.mjs new file mode 100644 index 000000000..f25371b03 --- /dev/null +++ b/apps/web/e2e/fixture-sandbox.mjs @@ -0,0 +1,43 @@ +const now = "2026-09-23T08:00:00Z"; +const node = (id, name, online = true) => ({ id, name, provider: "docker", online, provider_ready: online, diagnostic: "", + last_seen_at: now, max_active: 4, max_retained: 16, active: id === "node-local" ? 1 : 0, reserved: 0, + retained: 0, cleanup_pending: 0, created_at: now, running: id === "node-local" ? 1 : 0, snapshots: 0, + cpu_count: online ? 8 : null, available_memory_bytes: online ? 8589934592 : null, available_disk_bytes: online ? 34359738368 : null, +}); +let nodes = []; +let calls = []; +let provider = "docker"; +let diagnostic = ""; +export function resetSandboxFixture() { + nodes = [node("node-local", "Core server"), node("node-offline", "Offline host", false)]; calls = []; provider = "docker"; diagnostic = ""; +} +resetSandboxFixture(); +export function handleSandboxFixture(request, response, url, sendJson, sendError) { + const path = url.pathname; + if (path === "/__fixture/sandbox-diagnostic") { + const value = url.searchParams.get("value") ?? ""; + if (!["", "node_unavailable", "resource_missing", "compute_unconfirmed", "ownership_mismatch", "provider_unavailable"].includes(value)) { sendError(response, 400, "Unknown diagnostic"); return true; } + diagnostic = value; + nodes = nodes.map((entry) => entry.id === "node-local" ? { ...entry, online: value !== "node_unavailable", provider_ready: value !== "provider_unavailable", diagnostic: value === "provider_unavailable" ? value : "" } : entry); + sendJson(response, {}); return true; + } + if (path === "/__fixture/sandbox") { sendJson(response, { nodes, calls }); return true; } + if (path === "/__fixture/sandbox-microsandbox") { provider = "microsandbox"; sendJson(response, {}); return true; } + if (path === "/v1/sandbox/nodes") { sendJson(response, { data: nodes.map(({ id, name, online }) => ({ id, name, available: online })) }); return true; } + if (/^\/v1\/agents\/sessions\/[^/]+\/sandbox-placement$/.test(path)) { + sendJson(response, { node_id: "node-local", node_name: "Core server", available: !diagnostic, state: "active", compute_phase: "running", diagnostic }); return true; + } + if (!path.startsWith("/core/v1/sandbox/")) return false; + calls.push({ path, method: request.method, authorized: request.headers.authorization === "Bearer fixture-admin-key" }); + if (request.headers.authorization !== "Bearer fixture-admin-key") { sendError(response, 401, "A deployment admin key is required.", "invalid_admin_key"); return true; } + if (path.endsWith("/deployment")) sendJson(response, { installation_id: "fixture-installation", provider, maintenance: false, owner_epoch: 1 }); + else if (path.endsWith("/enrollment-tokens")) sendJson(response, { token: "fixture-once-token", expires_at: "2026-09-23T09:00:00Z" }); + else if (path.endsWith("/allocations")) sendJson(response, { data: path.includes("node-local") ? [{ id: "allocation-1", node_id: "node-local", session_id: "session_snapshot", tenant_id: "fixture-project", environment_id: "environment-1", state: "active", compute_phase: "running", initialization: "ready", diagnostic, created_at: now }] : [] }); + else if (request.method === "DELETE") { + const id = path.split("/").at(-1); + if (id === "node-local") sendError(response, 409, "Node has active allocations or retained resources.", "runtime_node_in_use"); + else { nodes = nodes.filter((entry) => entry.id !== id); sendJson(response, { id, deleted: true }); } + } else if (path.endsWith("/nodes")) sendJson(response, { data: nodes }); + else sendError(response, 404, "Unknown sandbox fixture route."); + return true; +} diff --git a/apps/web/e2e/sandbox-manager.spec.ts b/apps/web/e2e/sandbox-manager.spec.ts new file mode 100644 index 000000000..547a22c2b --- /dev/null +++ b/apps/web/e2e/sandbox-manager.spec.ts @@ -0,0 +1,152 @@ +import { expect, test, type Page } from "@playwright/test"; +const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; +async function connectAdmin(page: Page) { + await page.getByRole("button", { name: "Hosted Sandbox Manager", exact: true }).click(); + await page.getByLabel("Deployment admin key").fill("fixture-admin-key"); + await page.getByRole("button", { name: "Connect admin", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Nodes", exact: true })).toBeVisible(); +} +test.beforeEach(async ({ page, request }) => { + await request.post(`${fixture}/__fixture/reset`); + await page.goto("/"); + await expect(page.getByRole("button", { name: "Sessions", exact: true })).toBeVisible(); +}); +test("admin access, node health, guarded removal and enrollment remain separate from project credentials", async ({ page, request }) => { + await page.getByRole("button", { name: "Hosted Sandbox Manager", exact: true }).click(); + expect((await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls).toHaveLength(0); + await page.getByLabel("Deployment admin key").fill("project-key"); + await page.getByRole("button", { name: "Connect admin", exact: true }).click(); + await expect(page.getByRole("alert")).toContainText("deployment admin key is required"); + await page.getByRole("button", { name: "Disconnect admin" }).click(); + await connectAdmin(page); + await expect(page.getByRole("region", { name: "Sandbox nodes", exact: true })).toContainText("Provider ready"); + await expect(page.getByRole("region", { name: "Sandbox nodes", exact: true })).toContainText("Host metrics unavailable"); + await expect(page.getByRole("region", { name: "Sandbox allocations", exact: true })).toContainText("session_snapshot"); + await page.getByRole("button", { name: "Remove Core server", exact: true }).click(); + await page.getByRole("button", { name: "Confirm removal" }).click(); + await expect(page.getByRole("alert")).toContainText("active allocations or retained resources"); + await expect(page.getByRole("button", { name: "Remove Core server", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Cancel removal" }).click(); + await page.getByRole("button", { name: "Remove Offline host", exact: true }).click(); + await page.getByRole("button", { name: "Confirm removal" }).click(); + await expect(page.getByRole("button", { name: "Remove Offline host", exact: true })).toHaveCount(0); + await page.getByLabel("Core URL reachable from the node").fill("https://core.example"); + await page.getByRole("button", { name: "Generate enrollment command" }).click(); + await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/fixture-once-token/); + await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/--enrollment-token-file/); + const storage = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } })); + expect(storage).not.toContain("fixture-admin-key"); expect(storage).not.toContain("fixture-once-token"); + expect(page.url()).not.toContain("fixture-admin-key"); + await page.reload(); + await expect(page.getByLabel("Deployment admin key")).toHaveValue(""); + await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); +}); +test("microsandbox shares the manager and mobile tables stay contained", async ({ page, request }) => { + await request.post(`${fixture}/__fixture/sandbox-microsandbox`); + await page.setViewportSize({ width: 390, height: 844 }); + await connectAdmin(page); + await expect(page.locator(".sandbox-summary")).toContainText("microsandbox"); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + const table = page.getByRole("region", { name: "Sandbox nodes", exact: true }); + await expect(table).toBeVisible(); + const bounds = await table.boundingBox(); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390); + await expect(page.getByRole("button", { name: "Disconnect admin" })).toBeInViewport(); + await page.getByLabel("Core URL reachable from the node").scrollIntoViewIfNeeded(); + await expect(page.getByLabel("Core URL reachable from the node")).toBeInViewport(); +}); +test("hosted creation defaults to automatic and an explicit unavailable node is never replaced", async ({ page }) => { + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await page.getByRole("button", { name: "New Session", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Create a Session" }); + await dialog.getByLabel("Saved Agent", { exact: true }).selectOption("agent_b"); + await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); + const advanced = dialog.getByRole("button", { name: /Advanced settings/ }); + if (await advanced.getAttribute("aria-expanded") !== "true") await advanced.click(); + await expect(dialog.getByLabel("Sandbox node", { exact: true })).toHaveValue(""); + await dialog.getByLabel("Sandbox node", { exact: true }).selectOption("node-local"); + const creates: Array> = []; + await page.route("**/v1/agents/sessions", async (route) => { + if (route.request().method() !== "POST") return route.continue(); + creates.push(route.request().postDataJSON()); + await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { code: "runtime_node_unavailable", message: "Selected sandbox node is unavailable.", type: "server_error" } }) }); + }); + await dialog.getByRole("button", { name: "Create Session", exact: true }).click(); + await expect(dialog.getByRole("alert")).toContainText("Selected sandbox node is unavailable"); + await expect(dialog.getByLabel("Sandbox node", { exact: true })).toHaveValue("node-local"); + expect(creates).toHaveLength(1); + expect(creates[0]?.x_agents_core).toEqual({ sandbox_node_id: "node-local" }); +}); +test("Session details show the actual Core placement", async ({ page }) => { + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toContainText("Core server"); + await expect(dialog).toContainText("node-local"); +}); +test("empty nodes and a failed refresh have distinct states", async ({ page }) => { + await page.route("**/core/v1/sandbox/nodes", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ data: [] }) })); + await connectAdmin(page); + await expect(page.getByText("No nodes registered. Add a node to provide hosted capacity.")).toBeVisible(); + await expect(page.getByText("No sandbox allocations.")).toBeVisible(); + await page.route("**/core/v1/sandbox/deployment", (route) => route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { message: "Deployment unavailable." } }) })); + await page.getByRole("button", { name: "Refresh sandbox state" }).click(); + await expect(page.getByRole("alert")).toContainText("Previously loaded state is shown below"); +}); +test("late placement reads cannot replace another Session's placement", async ({ page, request }) => { + const second = await request.post(`${fixture}/v1/agents/sessions`, { + headers: { "OpenAI-Beta": "agents=v1", "Idempotency-Key": "placement-second" }, + data: { agent_id: "agent_b", environment: { type: "none" }, input: "Read placement", metadata: { title: "Placement second" }, stream: false }, + }); + expect(second.status()).toBe(201); + const secondId = (await second.json()).id; + let releaseOld: () => void = () => {}; + const oldReleased = new Promise((resolve) => { releaseOld = resolve; }); + let oldRequested = false; + await page.route("**/v1/agents/sessions/*/sandbox-placement", async (route) => { + const isSecond = route.request().url().includes(secondId); + if (!isSecond) { oldRequested = true; await oldReleased; } + await route.fulfill({ contentType: "application/json", body: JSON.stringify({ node_id: isSecond ? "new-node" : "old-node", node_name: isSecond ? "Second placement" : "Old placement", available: true, state: "active", compute_phase: "running" }) }).catch(() => {}); + }); + await page.reload(); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await page.locator('.session-row-action[aria-label="Manage Lifecycle Agent"]').click(); + await expect.poll(() => oldRequested).toBe(true); + await page.getByRole("button", { name: "Close dialog", exact: true }).click(); + await page.locator('.session-row-action[aria-label="Manage Placement second"]').click(); + await expect(page.getByRole("dialog")).toContainText("Second placement"); + releaseOld(); + await expect(page.getByRole("dialog")).not.toContainText("Old placement"); +}); +test("disconnect diagnostics clear after reconnection in manager and Session details", async ({ page, request }) => { + await request.post(`${fixture}/__fixture/sandbox-diagnostic?value=node_unavailable`); + await connectAdmin(page); + await expect(page.getByRole("region", { name: "Sandbox allocations", exact: true })).toContainText("Node disconnected"); + await expect(page.getByRole("region", { name: "Sandbox allocations", exact: true })).toContainText("Existing resources stay assigned"); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await page.locator(".conversation-session-action").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toContainText("Node disconnected"); + await request.post(`${fixture}/__fixture/sandbox-diagnostic?value=`); + await dialog.getByRole("button", { name: "Refresh placement" }).click(); + await expect(dialog).toContainText("Available · Recorded allocation"); + await expect(dialog).not.toContainText("Node disconnected"); + await page.getByRole("button", { name: "Close dialog", exact: true }).click(); + await connectAdmin(page); + await expect(page.getByRole("region", { name: "Sandbox allocations", exact: true })).toContainText("No reported issue"); + await expect(page.getByRole("region", { name: "Sandbox allocations", exact: true })).not.toContainText("Node disconnected"); +}); +test("a missing resource preserves ownership and offers inspection without replacement", async ({ page, request }) => { + await request.post(`${fixture}/__fixture/sandbox-diagnostic?value=resource_missing`); + await connectAdmin(page); + const allocations = page.getByRole("region", { name: "Sandbox allocations", exact: true }); + await expect(allocations).toContainText("Sandbox resource missing"); + await expect(allocations).toContainText("retains the ownership record"); + await expect(allocations).toContainText("does not create a replacement automatically"); + await page.getByRole("button", { name: "Sessions", exact: true }).click(); + await page.locator(".conversation-session-action").click(); + await expect(page.getByRole("dialog")).toContainText("Sandbox resource missing"); + await expect(page.getByRole("dialog")).toContainText("Check the provider resource on the assigned node"); + const requests = await (await request.get(`${fixture}/__fixture/requests`)).json(); + expect(requests.filter((entry: { method: string; path: string }) => entry.method === "POST" && entry.path === "/v1/agents/sessions")).toHaveLength(0); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4b0e787d6..11e5ac96a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,4 @@ -import { Layers3, Settings2 } from "lucide-react"; +import { Settings2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AgentCoreError } from "@agents-core-web/agents-client"; @@ -16,6 +16,9 @@ import type { UpdateAgentInput, } from "@agents-core-web/agents-client"; +import { SandboxManagerView } from "./features/sandbox/SandboxManagerView"; +import { SandboxProvider } from "./features/sandbox/SandboxContext"; +import { SystemNavigation } from "./components/SystemNavigation"; import { ConnectionModal } from "./components/ConnectionModal"; import { CreateMenu } from "./components/CreateMenu"; import { ProductNavigation, type ProductView } from "./components/ProductNavigation"; @@ -127,13 +130,13 @@ import { waitForStreamReconnect, } from "./lib/stream-reconnect"; -type View = ProductView | "system"; +type View = ProductView | "system" | "sandbox"; function viewFromLocation(): View { if (typeof window === "undefined") return "dashboard"; const candidate = window.location.hash.slice(1); if (candidate === "templates" && __AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS__) return "templates"; - return candidate === "agents" || candidate === "sessions" || candidate === "vaults" || candidate === "system" + return candidate === "agents" || candidate === "sessions" || candidate === "vaults" || candidate === "system" || candidate === "sandbox" ? candidate : "dashboard"; } @@ -1555,6 +1558,7 @@ export function App() { metadata: input.metadata, stream: input.stream, vaultIds: vaultPlan.vaultIds, + sandboxNodeId: input.sandboxNodeId, }); const openSession = (session: AgentSession) => { @@ -2102,7 +2106,7 @@ export function App() { }, []); return ( -
+
Skip to main content
); } diff --git a/apps/web/src/components/SystemNavigation.tsx b/apps/web/src/components/SystemNavigation.tsx new file mode 100644 index 000000000..6561a2078 --- /dev/null +++ b/apps/web/src/components/SystemNavigation.tsx @@ -0,0 +1,12 @@ +import { Layers3, Server } from "lucide-react"; +export type SystemView = "system" | "sandbox"; +export function SystemNavigation({ active, onSelect }: { active: SystemView | null; onSelect: (view: SystemView) => void }) { + return ; +} diff --git a/apps/web/src/features/sandbox/NodeHealth.tsx b/apps/web/src/features/sandbox/NodeHealth.tsx new file mode 100644 index 000000000..0e7805e53 --- /dev/null +++ b/apps/web/src/features/sandbox/NodeHealth.tsx @@ -0,0 +1,14 @@ +import { SandboxDiagnostic } from "./SandboxDiagnostic"; +import type { SandboxNode } from "@agents-core-web/agents-client"; +function bytes(value: number | null): string { + if (value === null) return "Unavailable"; + return `${(value / 1024 ** 3).toLocaleString(undefined, { maximumFractionDigits: 1 })} GiB`; +} +export function NodeHealth({ node }: { node: SandboxNode }) { + return
+ {node.online ? "Online" : "Offline"} · {!node.online ? "Provider status unconfirmed" : node.provider_ready ? "Provider ready" : "Provider unavailable"} + + Last seen: {node.last_seen_at ? new Date(node.last_seen_at).toLocaleString() : "Never"} + {node.online ? {node.cpu_count ?? "Unavailable"} CPUs · {bytes(node.available_memory_bytes)} memory free · {bytes(node.available_disk_bytes)} disk free : Host metrics unavailable (stale heartbeat)} +
; +} diff --git a/apps/web/src/features/sandbox/SandboxContext.tsx b/apps/web/src/features/sandbox/SandboxContext.tsx new file mode 100644 index 000000000..ac91fdebc --- /dev/null +++ b/apps/web/src/features/sandbox/SandboxContext.tsx @@ -0,0 +1,18 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { SandboxProjectClient } from "@agents-core-web/agents-client"; +import { isLocalProxyBaseUrl, type CoreConnection } from "../../lib/connection"; + +const SandboxContext = createContext(null); +export function SandboxProvider({ connection, children }: { connection: CoreConnection; children: ReactNode }) { + const client = useMemo(() => new SandboxProjectClient({ + baseUrl: connection.baseUrl, + token: isLocalProxyBaseUrl(connection.baseUrl) ? undefined : connection.token, + }), [connection]); + return {children}; +} +export function useSandboxClient() { return useContext(SandboxContext); } + +export function sandboxAdminBaseUrl(projectBaseUrl: string): string { + if (isLocalProxyBaseUrl(projectBaseUrl)) return "/core/v1/sandbox"; + return `${projectBaseUrl.replace(/\/+$/, "").replace(/\/v1$/, "")}/core/v1/sandbox`; +} diff --git a/apps/web/src/features/sandbox/SandboxDiagnostic.tsx b/apps/web/src/features/sandbox/SandboxDiagnostic.tsx new file mode 100644 index 000000000..285ceda06 --- /dev/null +++ b/apps/web/src/features/sandbox/SandboxDiagnostic.tsx @@ -0,0 +1,9 @@ +import { sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic"; + +export function SandboxDiagnostic({ diagnostic }: { diagnostic?: string }) { + const message = sandboxDiagnosticMessage(diagnostic); + if (!message) return null; + return
+ {message.label}{message.advice} +
; +} diff --git a/apps/web/src/features/sandbox/SandboxManagerView.css b/apps/web/src/features/sandbox/SandboxManagerView.css new file mode 100644 index 000000000..9723d266c --- /dev/null +++ b/apps/web/src/features/sandbox/SandboxManagerView.css @@ -0,0 +1,23 @@ +.sandbox-manager { display: grid; grid-template-columns: minmax(0, 1fr); align-content: start; gap: 24px; padding: 28px; min-width: 0; min-height: 0; flex: 1; overflow-y: auto; } +.sandbox-manager .form-stack, .sandbox-manager section, .sandbox-heading > div { min-width: 0; } +.sandbox-manager h1 { font-size: 24px; font-weight: 600; margin: 0 0 8px; } +.sandbox-manager h2 { font-size: 16px; font-weight: 600; margin: 0 0 12px; } +.sandbox-manager p { color: var(--fg-muted); line-height: 1.6; margin: 0 0 12px; } +.sandbox-heading, .sandbox-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; } +.sandbox-access, .sandbox-enrollment { max-width: 760px; } +.sandbox-summary { display: grid; grid-template-columns: 1fr 1fr 2fr; margin: 0; border: 1px solid var(--line); } +.sandbox-summary > div { min-width: 0; padding: 18px; } +.sandbox-summary dt { font-size: 12px; color: var(--fg-muted); margin-bottom: 8px; } +.sandbox-summary dd { margin: 0; overflow-wrap: anywhere; } +.sandbox-table-scroll { overflow-x: auto; border: 1px solid var(--line); max-width: 100%; } +.sandbox-manager table { width: 100%; border-collapse: collapse; font-size: 13px; text-align: left; } +.sandbox-manager th, .sandbox-manager td { padding: 14px; border-bottom: 1px solid var(--line); white-space: nowrap; } +.sandbox-manager th { font-size: 12px; font-weight: 500; color: var(--fg-muted); } +.sandbox-manager td small { display: block; color: var(--fg-muted); margin-top: 4px; } +.sandbox-manager .sandbox-error { color: var(--danger, #c44242); } +.sandbox-confirm { padding: 16px; border: 1px solid var(--line); margin-top: 16px; overflow-wrap: anywhere; } +.sandbox-manager textarea { font-family: monospace; font-size: 12px; resize: vertical; } +@media (max-width: 600px) { .sandbox-manager { padding: 18px 14px; } .sandbox-summary { grid-template-columns: 1fr; } } + +.sandbox-diagnostic { max-width: 360px; white-space: normal; line-height: 1.5; margin: 6px 0; } +.sandbox-diagnostic small { display: block; color: var(--fg-muted); margin-top: 4px; } diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx new file mode 100644 index 000000000..5dcac3c90 --- /dev/null +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -0,0 +1,112 @@ +import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; +import { SandboxAdminClient, type SandboxAllocation, type SandboxDeployment, type SandboxNode } from "@agents-core-web/agents-client"; +import { isValidDirectCoreBaseUrl } from "../../lib/connection"; +import { sandboxAdminBaseUrl } from "./SandboxContext"; +import { SandboxDiagnostic } from "./SandboxDiagnostic"; +import { NodeHealth } from "./NodeHealth"; +import { enrollmentCommand } from "./enrollment-command"; +import "./SandboxManagerView.css"; + +function message(error: unknown): string { + return error instanceof Error ? error.message : "The sandbox request failed."; +} + +export function SandboxManagerView({ coreBaseUrl }: { coreBaseUrl: string }) { + const [draft, setDraft] = useState(""); + const [credential, setCredential] = useState(""); + function connect(event: FormEvent) { + event.preventDefault(); + if (draft.trim()) { setCredential(draft.trim()); setDraft(""); } + } + return
+

Hosted Sandbox Manager

Deployment provider, runtime nodes and Session allocations.

+ {credential ? : null} +
+ {!credential ?
+

Deployment administrator access

+

Enter the separate deployment admin key. It stays in memory until you leave this page or disconnect.

+ + +
: } +
; +} + +function SandboxManager({ coreBaseUrl, credential }: { coreBaseUrl: string; credential: string }) { + const client = useMemo(() => new SandboxAdminClient({ baseUrl: sandboxAdminBaseUrl(coreBaseUrl), token: credential }), [coreBaseUrl, credential]); + const [snapshot, setSnapshot] = useState<{ deployment: SandboxDeployment; nodes: SandboxNode[]; allocations: SandboxAllocation[] } | null>(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [revision, setRevision] = useState(0); + const [removeId, setRemoveId] = useState(null); + const [enrollment, setEnrollment] = useState<{ token: string; expires_at: string } | null>(null); + const [coreUrl, setCoreUrl] = useState(() => coreBaseUrl.startsWith("http") ? coreBaseUrl.replace(/\/v1\/?$/, "") : ""); + const lifetime = useRef(null); + useEffect(() => { + const controller = new AbortController(); lifetime.current = controller; + return () => { controller.abort(); lifetime.current = null; }; + }, []); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); setError(null); + void (async () => { + const [deployment, nodes] = await Promise.all([client.retrieveDeployment({ signal: controller.signal }), client.listNodes({ signal: controller.signal })]); + const allocations = await Promise.all(nodes.data.map((node) => client.listAllocations(node.id, { signal: controller.signal }))); + if (!controller.signal.aborted) setSnapshot({ deployment, nodes: nodes.data, allocations: allocations.flatMap((page) => page.data) }); + })().catch((error) => { if (!controller.signal.aborted) setError(message(error)); }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [client, revision]); + async function enroll() { + const controller = lifetime.current; + if (!controller || busy) return; + setBusy(true); setError(null); setEnrollment(null); + try { + const result = await client.createEnrollment({ signal: controller.signal }); + if (!controller.signal.aborted) setEnrollment(result); + } catch (error) { if (!controller.signal.aborted) setError(message(error)); } + finally { if (!controller.signal.aborted) setBusy(false); } + } + async function remove() { + const controller = lifetime.current; + if (!controller || !removeId || busy) return; + setBusy(true); setError(null); + try { + const result = await client.removeNode(removeId, { signal: controller.signal }); + if (!result.deleted || result.id !== removeId) throw new Error("Core did not confirm node removal. Refresh to check its state."); + if (!controller.signal.aborted) { setRemoveId(null); setRevision((v) => v + 1); } + } catch (error) { if (!controller.signal.aborted) setError(message(error)); } + finally { if (!controller.signal.aborted) setBusy(false); } + } + return
+
{loading ? Loading sandbox state… : null}
+ {error ?

{error}{snapshot ? " Previously loaded state is shown below." : ""}

: null} + {snapshot ? <> +
+
Provider
{snapshot.deployment.provider === "docker" ? "Docker" : "microsandbox"}
+
Maintenance
{snapshot.deployment.maintenance ? "Enabled" : "Off"}
+
Installation
{snapshot.deployment.installation_id}
+
+

Nodes

Local nodes run on the Core server. Capacity and counts are reported by Core.

+ {snapshot.nodes.length ?
+ {snapshot.nodes.map((node) => )} +
NodeHealthActive / limitReservedRetained / limitRunning / snapshotsCleanup pendingActions
{node.name}{node.id}{node.active} / {node.max_active}{node.reserved}{node.retained} / {node.max_retained}{node.running} / {node.snapshots}{node.cleanup_pending}
:

No nodes registered. Add a node to provide hosted capacity.

} + {removeId ?

Remove node {removeId}? Core rejects removal while allocations or retained resources remain.

: null} +
+

Allocations

+ {snapshot.allocations.length ?
+ {snapshot.allocations.map((allocation) => )} +
SessionNodeRecorded stateRecorded computeHealth
{allocation.session_id}{snapshot.nodes.find((node) => node.id === allocation.node_id)?.name ?? allocation.node_id}{allocation.state}{allocation.compute_phase}{!allocation.diagnostic ? "No reported issue" : null}
:

No sandbox allocations.

} +
+

Add node

+

Install parsar-sandbox-node and prepare its {snapshot.deployment.provider} provider configuration on the target host. Adjust the absolute paths, node name and capacity in the command before running it.

+ + {!enrollment ? : <> +

One-time enrollment token expires {new Date(enrollment.expires_at).toLocaleString()}. Save the command now; it is cleared when you leave this page.

+