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
12 changes: 12 additions & 0 deletions controlplane/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,11 @@ async function replenishWarmPool(env: EnvWithBindings): Promise<void> {
volumeId,
image,
region,
// Warm pool stays genuinely warm — Fly must NOT auto-stop idle warm machines,
// otherwise they cold-cycle (stop→autostart→stop) and a claim hits a stopped
// machine that must cold-boot ("stuck connecting"). The pool is small
// (WARM_POOL_TARGET) so the always-on cost is bounded.
autostop: 'off',
env: {
SANDBOX_INTERNAL_TOKEN: env.SANDBOX_INTERNAL_TOKEN || '',
CONTROLPLANE_URL: env.FLY_SANDBOX_CONTROLPLANE_URL || 'https://api.orcabot.com',
Expand Down Expand Up @@ -2448,6 +2453,13 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick<E
return framedResponse;
}

// GET /dashboards/:id/sandbox/status - Read-only VM status for the traffic light
if (segments[0] === 'dashboards' && segments.length === 4 && segments[2] === 'sandbox' && segments[3] === 'status' && method === 'GET') {
const authError = requireAuth(auth);
if (authError) return authError;
return sessions.getDashbоardSandbоxStatus(env, segments[1], auth.user!.id);
}

// GET /dashboards/:id/metrics - Dashboard-scoped sandbox metrics
if (segments[0] === 'dashboards' && segments.length === 3 && segments[2] === 'metrics' && method === 'GET') {
const authError = requireAuth(auth);
Expand Down
9 changes: 8 additions & 1 deletion controlplane/src/sandbox/fly-machines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,14 @@ export class FlyMachinesClient {
image: string;
region: string;
env: Record<string, string>;
// Fly proxy autostop behavior when the machine is idle:
// - 'off' : never auto-stop (used for the warm pool — keep it genuinely warm)
// - 'suspend' : pause + snapshot RAM, resume in ~sub-second (used for dashboard VMs)
// - 'stop' : full shutdown, cold-boot on resume (legacy default)
// Defaults to 'suspend' so a forgotten caller gets fast-resume, not cold-boot churn.
autostop?: 'off' | 'stop' | 'suspend';
}): FlyCreateMachineRequest {
const autostop = opts.autostop ?? 'suspend';
return {
name: `orcabot-${opts.dashboardId.slice(0, 8)}-${crypto.randomUUID().slice(0, 6)}`,
region: opts.region,
Expand Down Expand Up @@ -404,7 +411,7 @@ export class FlyMachinesClient {
hard_limit: 100,
},
autostart: true,
autostop: 'stop',
autostop,
},
],
mounts: [
Expand Down
114 changes: 109 additions & 5 deletions controlplane/src/sessions/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,27 @@ async function getMachineStateCached(fly: FlyMachinesClient, machineId: string):
}
}

// Cached sandbox /health probe for the status light, so polling doesn't round-trip
// to the VM every time. A machine can be Fly-'started' while the server is still
// booting — this is what distinguishes "warmed up" (green) from "starting" (orange).
const sandboxHealthCache = new Map<string, { healthy: boolean; checkedAt: number }>();
const SANDBOX_HEALTH_TTL_MS = 8000;

async function getSandbоxHealthCached(env: EnvWithDriveCache, machineId: string): Promise<boolean> {
const cached = sandboxHealthCache.get(machineId);
const now = Date.now();
if (cached && now - cached.checkedAt < SANDBOX_HEALTH_TTL_MS) return cached.healthy;
let healthy = false;
try {
const res = await sandboxFetch(env, '/health', { machineId, timeoutMs: 2500, retries: 0 });
healthy = res.ok;
} catch {
healthy = false;
}
sandboxHealthCache.set(machineId, { healthy, checkedAt: now });
return healthy;
}

/**
* Ensures a dashboard has exactly one sandbox VM.
*
Expand Down Expand Up @@ -653,9 +674,9 @@ async function claimWarmMachine(
return null;
}

// Disable autostop so Fly doesn't hibernate this active dashboard machine.
// Warm pool machines have autostop:'stop' for hibernation; once claimed, disable it.
await fly.disableAutostop(machineId); // best-effort, non-throwing
// Warm machines are created with autostop:'off' (genuinely warm), so there's no
// hibernation to disable on claim — and avoiding a config update here means no
// machine restart right when the user claims it.

// Create sandbox session pinned to this machine
const sandboxSession = await sandbox.createSessiоn(dashboardId, mcpToken, machineId);
Expand Down Expand Up @@ -747,6 +768,10 @@ async function coldProvisionMachine(
volumeId,
image,
region,
// Dashboard VMs suspend (RAM snapshot, ~sub-second resume) when idle instead of
// cold-stopping, so coming back to a dashboard doesn't cold-boot ("stuck
// connecting"). The cron escalates long-idle VMs from suspended → stopped.
autostop: 'suspend',
env: {
SANDBOX_INTERNAL_TOKEN: env.SANDBOX_INTERNAL_TOKEN,
CONTROLPLANE_URL: env.FLY_SANDBOX_CONTROLPLANE_URL || 'https://api.orcabot.com',
Expand All @@ -763,8 +788,10 @@ async function coldProvisionMachine(
// Step 3: Wait for machine to be ready
await fly.waitForState(machineId, 'started', 90);

// Disable autostop so Fly doesn't hibernate this active dashboard machine
await fly.disableAutostop(machineId); // best-effort, non-throwing
// NOTE: do NOT force autostop off here. Dashboard VMs are created with
// autostop:'suspend' (fast RAM-snapshot resume) so idle dashboards suspend
// instead of cold-stopping. A config update here would (a) override that and
// (b) trigger a machine restart right after provisioning.

// Step 4: Create sandbox session with retry (server may not be listening immediately after started)
let sandboxSession!: { id: string; machineId?: string };
Expand Down Expand Up @@ -1333,7 +1360,11 @@ export async function createSessiоn(
// Use ensureDashboardSandbox which checks machine health via Fly API,
// handles destroyed machines (FlyMachineNotFoundError → reprovision),
// and validates the session is still reachable on the sandbox.
// Perf: this is the "first connect to sandbox" cost (warm-pool claim is fast,
// cold provision is slow). Logged so we can attribute terminal startup latency.
const ensureStart = Date.now();
const sandboxResult = await ensureDashbоardSandbоx(env, dashboardId, userId, preferredRegion);
console.log(`[perf][createSession] ensureDashboardSandbox=${Date.now() - ensureStart}ms dashboard=${dashboardId}`);
if (sandboxResult instanceof Response) {
// Access denied or other error — clean up the creating session record
await env.DB.prepare(`DELETE FROM sessions WHERE id = ?`).bind(id).run();
Expand Down Expand Up @@ -1777,6 +1808,79 @@ export async function stоpDashbоardBrowser(
return new Response(null, { status: 204 });
}

// REVISION: sandbox-status-light-v1
// Read-only VM status for the dashboard "traffic light". Does NOT provision —
// reports the current Fly machine state, mapped to a small status enum:
// ready (green) — machine started/running
// starting (orange) — booting/transitioning (created/starting/replacing)
// asleep (red) — stopped/suspended, or no VM provisioned yet
// unknown (orange) — couldn't determine (transient Fly error / provisioning off)
// Uses getMachineStateCached so polling doesn't hammer the Fly API (anti-429).
export async function getDashbоardSandbоxStatus(
env: EnvWithDriveCache,
dashboardId: string,
userId: string,
): Promise<Response> {
const access = await env.DB.prepare(
`SELECT role FROM dashboard_members WHERE dashboard_id = ? AND user_id = ?`
).bind(dashboardId, userId).first<{ role: string }>();
if (!access) {
return Response.json({ error: 'E79201: Not found or no access' }, { status: 404 });
}

const sandbox = await getDashbоardSandbоx(env, dashboardId);
if (!sandbox?.sandbox_machine_id) {
// No VM (or no machine id yet) — asleep until the first terminal/browser opens.
return Response.json({ state: 'asleep', flyState: null, machineId: null });
}

const flyProvisioningEnabled = env.FLY_PROVISIONING_ENABLED === 'true'
&& Boolean(env.FLY_API_TOKEN) && Boolean(env.FLY_APP_NAME);
if (!flyProvisioningEnabled) {
// Can't query Fly (e.g. local/desktop). A recorded session implies it's up.
return Response.json({ state: 'ready', flyState: null, machineId: sandbox.sandbox_machine_id });
}

const fly = new FlyMachinesClient(env.FLY_APP_NAME!, env.FLY_API_TOKEN!);
let flyState: string;
try {
flyState = await getMachineStateCached(fly, sandbox.sandbox_machine_id);
} catch (err) {
if (err instanceof FlyMachineNotFoundError) {
return Response.json({ state: 'asleep', flyState: 'not_found', machineId: sandbox.sandbox_machine_id });
}
// Transient (e.g. 429) — report in-between rather than failing the poll.
return Response.json({ state: 'unknown', flyState: null, machineId: sandbox.sandbox_machine_id });
}

// Machine running at the Fly level — but only call it "ready" (green) once the
// sandbox server actually answers /health. Started-but-not-yet-serving = orange.
if (flyState === 'started') {
const healthy = await getSandbоxHealthCached(env, sandbox.sandbox_machine_id);
return Response.json({
state: healthy ? 'ready' : 'starting',
flyState,
machineId: sandbox.sandbox_machine_id,
});
}

let state: 'starting' | 'asleep' | 'unknown';
switch (flyState) {
case 'starting':
case 'created':
case 'replacing':
state = 'starting';
break;
case 'stopped':
case 'suspended':
state = 'asleep';
break;
default:
state = 'unknown';
}
return Response.json({ state, flyState, machineId: sandbox.sandbox_machine_id });
}

export async function getDashbоardBrowserStatus(
env: EnvWithDriveCache,
dashboardId: string,
Expand Down
7 changes: 7 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"workers:deploy:dev": "node scripts/workers-deploy-dev.mjs"
},
"dependencies": {
"@novnc/novnc": "^1.7.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4",
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/app/(app)/dashboards/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import {
import { PolicyEditorDialog } from "@/components/blocks/PolicyEditorDialog";
import { EgressApprovalDialog } from "@/components/EgressApprovalDialog";
import { EgressAllowlistPanel } from "@/components/EgressAllowlistPanel";
import { SandboxStatusLight } from "@/components/SandboxStatusLight";
import { WorkspaceSidebar } from "@/components/workspace";
import { ChatPanel } from "@/components/chat";
import { getUserSetup, dismissAiSetup } from "@/lib/api/cloudflare/user-setup";
Expand Down Expand Up @@ -3606,6 +3607,11 @@ export default function DashboardPage() {
</div>

<div className="flex items-center gap-3 justify-end">
{/* Sandbox VM traffic light */}
<div className="flex items-center px-1.5 py-1 bg-[var(--background)] rounded">
<SandboxStatusLight dashboardId={dashboardId} />
</div>

{/* Presence indicators */}
<div className="flex items-center gap-2">
<Tooltip
Expand Down
62 changes: 62 additions & 0 deletions frontend/src/components/SandboxStatusLight.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use client";

// Copyright 2026 Rob Macrae. All rights reserved.
// SPDX-License-Identifier: LicenseRef-Proprietary

// REVISION: sandbox-status-light-v1
//
// Traffic-light indicator for the dashboard's sandbox VM, shown in the control bar.
// green — VM ready/running (alive, warmed up)
// orange — in between (starting/resuming, or status temporarily unknown)
// red — asleep (stopped/suspended) or not provisioned yet
// Polls the read-only status endpoint (server-side cached, so polling is cheap).

import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import { getDashboardSandboxStatus, type SandboxState } from "@/lib/api/cloudflare/dashboards";

const COLORS: Record<SandboxState, { dot: string; label: string }> = {
ready: { dot: "bg-green-500", label: "Sandbox ready" },
starting: { dot: "bg-amber-500", label: "Sandbox starting…" },
asleep: { dot: "bg-red-500", label: "Sandbox asleep" },
unknown: { dot: "bg-amber-500", label: "Sandbox status unknown" },
};

interface SandboxStatusLightProps {
dashboardId: string;
/** Poll interval in ms (default 12s — server caches the underlying Fly call). */
pollMs?: number;
}

export function SandboxStatusLight({ dashboardId, pollMs = 12000 }: SandboxStatusLightProps) {
const { data } = useQuery({
queryKey: ["sandbox-status", dashboardId],
queryFn: () => getDashboardSandboxStatus(dashboardId),
enabled: Boolean(dashboardId),
refetchInterval: pollMs,
refetchOnWindowFocus: true,
staleTime: pollMs,
retry: false,
});

const state: SandboxState = data?.state ?? "unknown";
const { dot, label } = COLORS[state];
const title = data?.flyState ? `${label} (${data.flyState})` : label;

return (
<span
className="inline-flex items-center gap-1.5 px-1.5"
title={title}
aria-label={label}
role="status"
>
<span
className={`inline-block w-2.5 h-2.5 rounded-full ${dot} ${
state === "starting" ? "animate-pulse" : ""
}`}
/>
</span>
);
}

export default SandboxStatusLight;
Loading
Loading