diff --git a/CLAUDE.md b/CLAUDE.md index d3cfa02..3c0ddd5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ envd is a **completely independent Go module**. It is never imported by the main Startup (`cmd/control-plane/main.go`) wires: config (env vars) → pgxpool → `db.Queries` (sqlc-generated) → Connect RPC client to host agent → `api.Server`. Everything flows through constructor injection. -- **API Server** (`internal/api/server.go`): chi router with middleware. Creates handler structs (`sandboxHandler`, `execHandler`, `filesHandler`, etc.) injected with `db.Queries` and the host agent Connect RPC client. Routes under `/v1/sandboxes/*`. +- **API Server** (`internal/api/server.go`): chi router with middleware. Creates handler structs (`sandboxHandler`, `execHandler`, `filesHandler`, etc.) injected with `db.Queries` and the host agent Connect RPC client. Routes under `/v1/capsules/*`. - **Reconciler** (`internal/api/reconciler.go`): background goroutine (every 30s) that compares DB records against `agent.ListSandboxes()` RPC. Marks orphaned DB entries as "stopped". - **Dashboard** (SvelteKit + Tailwind + Bits UI, statically built and embedded via `go:embed`, served as catch-all at root) - **Database**: PostgreSQL via pgx/v5. Queries generated by sqlc from `db/queries/sandboxes.sql`. Migrations in `db/migrations/` (goose, plain SQL). @@ -147,19 +147,19 @@ HIBERNATED → RUNNING (cold snapshot resume, slower) ### Key Request Flows -**Sandbox creation** (`POST /v1/sandboxes`): +**Sandbox creation** (`POST /v1/capsules`): 1. API handler generates sandbox ID, inserts into DB as "pending" 2. RPC `CreateSandbox` → host agent → `sandbox.Manager.Create()` 3. Manager: resolve base rootfs → acquire shared loop device → create dm-snapshot (sparse CoW file) → allocate network slot → `CreateNetwork()` (netns + veth + tap + NAT) → `vm.Create()` (start Firecracker with `/dev/mapper/wrenn-{id}`, configure via HTTP API, boot) → `envdclient.WaitUntilReady()` (poll /health) → store in-memory state 4. API handler updates DB to "running" with host_ip -**Command execution** (`POST /v1/sandboxes/{id}/exec`): +**Command execution** (`POST /v1/capsules/{id}/exec`): 1. API handler verifies sandbox is "running" in DB 2. RPC `Exec` → host agent → `sandbox.Manager.Exec()` → `envdclient.Exec()` 3. envd client opens bidirectional Connect RPC stream (`process.Start`), collects stdout/stderr/exit_code 4. API handler checks UTF-8 validity (base64-encodes if binary), updates last_active_at, returns result -**Streaming exec** (`WS /v1/sandboxes/{id}/exec/stream`): +**Streaming exec** (`WS /v1/capsules/{id}/exec/stream`): 1. WebSocket upgrade, read first message for cmd/args 2. RPC `ExecStream` → host agent → `sandbox.Manager.ExecStream()` → `envdclient.ExecStream()` 3. envd client returns a channel of events; host agent forwards events through the RPC stream diff --git a/frontend/src/lib/api/capsules.ts b/frontend/src/lib/api/capsules.ts index 565f14f..3e8f7f3 100644 --- a/frontend/src/lib/api/capsules.ts +++ b/frontend/src/lib/api/capsules.ts @@ -17,11 +17,11 @@ export type Capsule = { export async function listCapsules(): Promise> { - return apiFetch('GET', '/api/v1/sandboxes'); + return apiFetch('GET', '/api/v1/capsules'); } export async function getCapsule(id: string): Promise> { - return apiFetch('GET', `/api/v1/sandboxes/${id}`); + return apiFetch('GET', `/api/v1/capsules/${id}`); } export type CreateCapsuleParams = { @@ -32,19 +32,19 @@ export type CreateCapsuleParams = { }; export async function createCapsule(params: CreateCapsuleParams): Promise> { - return apiFetch('POST', '/api/v1/sandboxes', params); + return apiFetch('POST', '/api/v1/capsules', params); } export async function pauseCapsule(id: string): Promise> { - return apiFetch('POST', `/api/v1/sandboxes/${id}/pause`); + return apiFetch('POST', `/api/v1/capsules/${id}/pause`); } export async function resumeCapsule(id: string): Promise> { - return apiFetch('POST', `/api/v1/sandboxes/${id}/resume`); + return apiFetch('POST', `/api/v1/capsules/${id}/resume`); } export async function destroyCapsule(id: string): Promise> { - return apiFetch('DELETE', `/api/v1/sandboxes/${id}`); + return apiFetch('DELETE', `/api/v1/capsules/${id}`); } export type Snapshot = { @@ -57,8 +57,8 @@ export type Snapshot = { platform: boolean; }; -export async function createSnapshot(sandboxId: string, name?: string): Promise> { - return apiFetch('POST', '/api/v1/snapshots', { sandbox_id: sandboxId, name }); +export async function createSnapshot(capsuleId: string, name?: string): Promise> { + return apiFetch('POST', '/api/v1/snapshots', { sandbox_id: capsuleId, name }); } export async function listSnapshots(typeFilter?: string): Promise> { diff --git a/frontend/src/lib/api/files.ts b/frontend/src/lib/api/files.ts index c1d664c..e89daad 100644 --- a/frontend/src/lib/api/files.ts +++ b/frontend/src/lib/api/files.ts @@ -53,12 +53,12 @@ export function formatFileSize(bytes: number): string { return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`; } -export async function listDir(sandboxId: string, path: string, depth = 1): Promise> { +export async function listDir(capsuleId: string, path: string, depth = 1): Promise> { try { const headers: Record = { 'Content-Type': 'application/json' }; if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; - const res = await fetch(`/api/v1/sandboxes/${sandboxId}/files/list`, { + const res = await fetch(`/api/v1/capsules/${capsuleId}/files/list`, { method: 'POST', headers, body: JSON.stringify({ path, depth }), @@ -72,12 +72,12 @@ export async function listDir(sandboxId: string, path: string, depth = 1): Promi } } -export async function readFile(sandboxId: string, path: string): Promise> { +export async function readFile(capsuleId: string, path: string): Promise> { try { const headers: Record = { 'Content-Type': 'application/json' }; if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; - const res = await fetch(`/api/v1/sandboxes/${sandboxId}/files/read`, { + const res = await fetch(`/api/v1/capsules/${capsuleId}/files/read`, { method: 'POST', headers, body: JSON.stringify({ path }), @@ -100,11 +100,11 @@ export async function readFile(sandboxId: string, path: string): Promise { +export async function downloadFile(capsuleId: string, path: string, filename: string): Promise { const headers: Record = { 'Content-Type': 'application/json' }; if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; - const res = await fetch(`/api/v1/sandboxes/${sandboxId}/files/read`, { + const res = await fetch(`/api/v1/capsules/${capsuleId}/files/read`, { method: 'POST', headers, body: JSON.stringify({ path }), diff --git a/frontend/src/lib/api/metrics.ts b/frontend/src/lib/api/metrics.ts index c3aaea8..4c192d3 100644 --- a/frontend/src/lib/api/metrics.ts +++ b/frontend/src/lib/api/metrics.ts @@ -15,8 +15,8 @@ export type MetricsResponse = { points: MetricPoint[]; }; -export async function fetchSandboxMetrics(id: string, range: MetricRange): Promise> { - return apiFetch('GET', `/api/v1/sandboxes/${id}/metrics?range=${range}`); +export async function fetchCapsuleMetrics(id: string, range: MetricRange): Promise> { + return apiFetch('GET', `/api/v1/capsules/${id}/metrics?range=${range}`); } export const METRIC_RANGES: MetricRange[] = ['5m', '10m', '1h', '6h', '24h']; diff --git a/frontend/src/lib/api/stats.ts b/frontend/src/lib/api/stats.ts index 3f85483..948ae12 100644 --- a/frontend/src/lib/api/stats.ts +++ b/frontend/src/lib/api/stats.ts @@ -24,7 +24,7 @@ export type StatsResponse = { }; export async function fetchStats(range: TimeRange): Promise> { - return apiFetch('GET', `/api/v1/sandboxes/stats?range=${range}`); + return apiFetch('GET', `/api/v1/capsules/stats?range=${range}`); } export const POLL_INTERVALS: Record = { diff --git a/frontend/src/lib/components/FilesTab.svelte b/frontend/src/lib/components/FilesTab.svelte index 6e221f8..effce24 100644 --- a/frontend/src/lib/components/FilesTab.svelte +++ b/frontend/src/lib/components/FilesTab.svelte @@ -11,11 +11,11 @@ import { tokenize, type ThemedToken } from '$lib/highlight'; type Props = { - sandboxId: string; + capsuleId: string; isRunning: boolean; }; - let { sandboxId, isRunning }: Props = $props(); + let { capsuleId, isRunning }: Props = $props(); // Directory navigation state let currentPath = $state('~'); @@ -124,7 +124,7 @@ dirLoading = true; dirError = null; const gen = ++dirGeneration; - const result = await listDir(sandboxId, currentPath); + const result = await listDir(capsuleId, currentPath); if (gen !== dirGeneration) return; // stale response if (result.ok) { entries = result.data.entries ?? []; @@ -159,7 +159,7 @@ fileLoading = true; const gen = ++fileGeneration; - const result = await readFile(sandboxId, entry.path); + const result = await readFile(capsuleId, entry.path); if (gen !== fileGeneration) return; // stale response — user clicked another file if (result.ok) { if (looksLikeBinary(result.data)) { @@ -197,7 +197,7 @@ if (!selectedFile || downloading) return; downloading = true; try { - await downloadFile(sandboxId, selectedFile.path, selectedFile.name); + await downloadFile(capsuleId, selectedFile.path, selectedFile.name); } catch { fileError = 'Download failed'; } @@ -214,7 +214,7 @@ async function navigateOrOpenFile(path: string) { // First try as directory - const dirResult = await listDir(sandboxId, path); + const dirResult = await listDir(capsuleId, path); if (dirResult.ok) { // Resolve actual path from entries (handles ~ expansion by envd) const resolvedEntries = dirResult.data.entries ?? []; @@ -245,7 +245,7 @@ // Navigate to parent directory currentPath = parentPath; pathInput = parentPath; - const parentResult = await listDir(sandboxId, parentPath); + const parentResult = await listDir(capsuleId, parentPath); if (parentResult.ok) { entries = parentResult.data.entries ?? []; // Find the file in parent listing diff --git a/frontend/src/lib/components/TerminalTab.svelte b/frontend/src/lib/components/TerminalTab.svelte index 0e38304..00df00a 100644 --- a/frontend/src/lib/components/TerminalTab.svelte +++ b/frontend/src/lib/components/TerminalTab.svelte @@ -3,12 +3,12 @@ import { auth } from '$lib/auth.svelte'; type Props = { - sandboxId: string; + capsuleId: string; isRunning: boolean; visible?: boolean; }; - let { sandboxId, isRunning, visible = true }: Props = $props(); + let { capsuleId, isRunning, visible = true }: Props = $props(); type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error'; @@ -93,7 +93,7 @@ function getWsUrl(): string { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const token = auth.token ? `?token=${encodeURIComponent(auth.token)}` : ''; - return `${proto}//${window.location.host}/api/v1/sandboxes/${sandboxId}/pty${token}`; + return `${proto}//${window.location.host}/api/v1/capsules/${capsuleId}/pty${token}`; } function wsSend(ws: WebSocket | null, data: string) { diff --git a/frontend/src/lib/highlight.ts b/frontend/src/lib/highlight.ts index 49523c9..93028ea 100644 --- a/frontend/src/lib/highlight.ts +++ b/frontend/src/lib/highlight.ts @@ -17,7 +17,7 @@ let loadingPromise: Promise> | null = null; const THEME = 'vesper'; // Extensions → shiki language IDs. -// Only map what we expect users to encounter in sandboxes. +// Only map what we expect users to encounter in capsules. const EXT_TO_LANG: Record = { // Go go: 'go', mod: 'go', sum: 'go', diff --git a/frontend/src/routes/admin/hosts/+page.svelte b/frontend/src/routes/admin/hosts/+page.svelte index 362b9ff..2cd4d06 100644 --- a/frontend/src/routes/admin/hosts/+page.svelte +++ b/frontend/src/routes/admin/hosts/+page.svelte @@ -77,7 +77,7 @@ // Delete confirmation let deleteTarget = $state(null); - let deletePreviewSandboxes = $state([]); + let deletePreviewCapsules = $state([]); let deletePreviewLoading = $state(false); let deleting = $state(false); let deleteError = $state(null); @@ -124,12 +124,12 @@ async function openDeleteConfirm(host: Host) { deleteTarget = host; deleteError = null; - deletePreviewSandboxes = []; + deletePreviewCapsules = []; deletePreviewLoading = true; const preview = await getDeletePreview(host.id); deletePreviewLoading = false; if (preview.ok) { - deletePreviewSandboxes = preview.data.sandbox_ids; + deletePreviewCapsules = preview.data.sandbox_ids; } } @@ -137,7 +137,7 @@ if (!deleteTarget) return; deleting = true; deleteError = null; - const result = await deleteHost(deleteTarget.id, deletePreviewSandboxes.length > 0); + const result = await deleteHost(deleteTarget.id, deletePreviewCapsules.length > 0); if (result.ok) { allHosts = allHosts.filter((h) => h.id !== deleteTarget!.id); deleteTarget = null; @@ -627,10 +627,10 @@ Checking active capsules… - {:else if deletePreviewSandboxes.length > 0} + {:else if deletePreviewCapsules.length > 0}

- {deletePreviewSandboxes.length} active capsule{deletePreviewSandboxes.length === 1 ? '' : 's'} will be destroyed. + {deletePreviewCapsules.length} active capsule{deletePreviewCapsules.length === 1 ? '' : 's'} will be destroyed.

All running workloads on this host will be terminated immediately. @@ -661,7 +661,7 @@ Deleting… {:else} - {deletePreviewSandboxes.length > 0 ? 'Force Delete' : 'Delete'} + {deletePreviewCapsules.length > 0 ? 'Force Delete' : 'Delete'} {/if}

diff --git a/frontend/src/routes/dashboard/capsules/[id]/+page.svelte b/frontend/src/routes/dashboard/capsules/[id]/+page.svelte index a90d334..a8bfb4d 100644 --- a/frontend/src/routes/dashboard/capsules/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/capsules/[id]/+page.svelte @@ -9,14 +9,14 @@ import FilesTab from '$lib/components/FilesTab.svelte'; import TerminalTab from '$lib/components/TerminalTab.svelte'; import { - fetchSandboxMetrics, + fetchCapsuleMetrics, METRIC_RANGES, METRIC_POLL_INTERVALS, type MetricRange, type MetricPoint } from '$lib/api/metrics'; - const sandboxId: string = $page.params.id ?? ''; + const capsuleId: string = $page.params.id ?? ''; let capsule = $state(null); let capsuleLoading = $state(true); @@ -96,7 +96,7 @@ ); async function loadCapsule() { - const result = await getCapsule(sandboxId); + const result = await getCapsule(capsuleId); if (result.ok) { capsule = result.data; capsuleError = null; @@ -108,7 +108,7 @@ async function loadMetrics() { if (!metricsAvailable) return; - const result = await fetchSandboxMetrics(sandboxId, range); + const result = await fetchCapsuleMetrics(capsuleId, range); if (result.ok) { points = result.data.points; metricsError = null; @@ -441,7 +441,7 @@ - Wrenn — {sandboxId} + Wrenn — {capsuleId}