fix(canvas): default headless workspaces to cost-efficient compute #1825

Merged
hongming merged 1 commits from fix/issue-1686-cost-efficient-workspace-defaults into main 2026-05-25 02:18:42 +00:00
7 changed files with 4469 additions and 40 deletions
+35
View File
@@ -0,0 +1,35 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
{
ignores: [
".next/**",
"coverage/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-require-imports": "warn",
"prefer-const": "warn",
"react-hooks/rules-of-hooks": "warn",
"react/display-name": "warn",
"react/no-unescaped-entities": "warn",
},
},
];
export default eslintConfig;
+4330 -1
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -6,7 +6,7 @@
"dev": "next dev --turbopack -p 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint .",
"test": "vitest run",
"test:coverage": "vitest run --coverage"
},
@@ -31,6 +31,7 @@
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.0.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.1.0",
"@types/node": "^25.6.0",
@@ -38,7 +39,8 @@
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"@tailwindcss/postcss": "^4.0.0",
"eslint": "^9.39.4",
"eslint-config-next": "^15.5.15",
"jsdom": "^29.1.1",
"postcss": "^8.5.13",
"tailwindcss": "^4.0.0",
+28 -17
View File
@@ -34,6 +34,10 @@ interface HermesProvider {
}
const DEFAULT_CREATE_MODEL = "anthropic:claude-opus-4-7";
const DEFAULT_HEADLESS_INSTANCE_TYPE = "t3.medium";
const DEFAULT_HEADLESS_ROOT_GB = 30;
const DEFAULT_DISPLAY_INSTANCE_TYPE = "t3.xlarge";
const DEFAULT_DISPLAY_ROOT_GB = 80;
// All providers supported by Hermes runtime via providers.resolve_provider().
// `defaultModel` is the slug injected into the workspace provision request
@@ -71,8 +75,8 @@ export function CreateWorkspaceButton() {
const [error, setError] = useState<string | null>(null);
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
const [displayEnabled, setDisplayEnabled] = useState(false);
const [displayInstanceType, setDisplayInstanceType] = useState("t3.xlarge");
const [displayRootGB, setDisplayRootGB] = useState("80");
const [displayInstanceType, setDisplayInstanceType] = useState(DEFAULT_DISPLAY_INSTANCE_TYPE);
const [displayRootGB, setDisplayRootGB] = useState(String(DEFAULT_DISPLAY_ROOT_GB));
const [displayResolution, setDisplayResolution] = useState("1920x1080");
// Templates fetched from /api/templates — drives the dynamic provider
// filter below. Same data source ConfigTab uses (PR #2454). When the
@@ -104,8 +108,9 @@ export function CreateWorkspaceButton() {
// Tier picker: on SaaS every workspace gets its own EC2 VM (Full Access
// by construction), so we hide the T1/T2/T3 Docker-sandbox tiers and
// lock to T4 — the full-host access tier, which maps to t3.large at the
// CP level. On self-hosted we still offer T1/T2/T3 because the Docker-
// lock to T4 — the full-host access tier. The EC2 size is controlled by
// the compute profile below. On self-hosted we still offer T1/T2/T3
// because the Docker-
// sandbox distinction is a real choice there; T4 is available too for
// operators who want the full-host tier.
//
@@ -230,8 +235,8 @@ export function CreateWorkspaceButton() {
setBudgetLimit("");
setError(null);
setDisplayEnabled(false);
setDisplayInstanceType("t3.xlarge");
setDisplayRootGB("80");
setDisplayInstanceType(DEFAULT_DISPLAY_INSTANCE_TYPE);
setDisplayRootGB(String(DEFAULT_DISPLAY_ROOT_GB));
setDisplayResolution("1920x1080");
setHermesProvider("anthropic");
setExternalRuntime("external");
@@ -293,18 +298,24 @@ export function CreateWorkspaceButton() {
parent_id: parentId || undefined,
budget_limit: parsedBudget,
...(!isExternal && !isHermes ? { model: DEFAULT_CREATE_MODEL } : {}),
...(displayEnabled
...(!isExternal
? {
compute: {
instance_type: displayInstanceType,
volume: { root_gb: Number.isFinite(parsedRootGB) ? parsedRootGB : 80 },
display: {
mode: "desktop-control",
protocol: "novnc",
width: Number.isFinite(displayWidth) ? displayWidth : 1920,
height: Number.isFinite(displayHeight) ? displayHeight : 1080,
},
},
compute: displayEnabled
? {
instance_type: displayInstanceType,
volume: { root_gb: Number.isFinite(parsedRootGB) ? parsedRootGB : DEFAULT_DISPLAY_ROOT_GB },
display: {
mode: "desktop-control",
protocol: "novnc",
width: Number.isFinite(displayWidth) ? displayWidth : 1920,
height: Number.isFinite(displayHeight) ? displayHeight : 1080,
},
}
: {
instance_type: DEFAULT_HEADLESS_INSTANCE_TYPE,
volume: { root_gb: DEFAULT_HEADLESS_ROOT_GB },
display: { mode: "none" },
},
}
: {}),
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
@@ -123,7 +123,7 @@ describe("CreateWorkspaceDialog", () => {
expect(body.parent_id).toBeUndefined();
});
it("omits compute config by default", async () => {
it("sends the cost-efficient headless compute profile by default", async () => {
await openDialog();
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "Plain Agent" },
@@ -134,10 +134,30 @@ describe("CreateWorkspaceDialog", () => {
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.compute).toBeUndefined();
expect(body.compute).toEqual({
instance_type: "t3.medium",
volume: { root_gb: 30 },
display: { mode: "none" },
});
expect(body.model).toBe("anthropic:claude-opus-4-7");
});
it("does not send managed compute for external agents", async () => {
await openDialog();
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "External Agent" },
});
fireEvent.click(screen.getByLabelText(/External agent/));
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
fireEvent.click(createBtn!);
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.compute).toBeUndefined();
expect(body.runtime).toBe("external");
});
it("sends display compute profile when desktop display is enabled", async () => {
await openDialog();
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
@@ -9,6 +9,8 @@ import type { WorkspaceCompute } from "@/store/socket";
const INSTANCE_TYPES = ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge", "m6i.large", "m6i.xlarge", "c6i.xlarge"];
const RUNTIME_OPTIONS = ["claude-code", "codex", "hermes", "openclaw", "langgraph", "kimi", "kimi-cli", "external"];
const RESOLUTIONS = ["1280x720", "1440x900", "1920x1080", "2560x1440"];
const DEFAULT_HEADLESS_INSTANCE_TYPE = "t3.medium";
const DEFAULT_HEADLESS_ROOT_GB = 30;
type Props = {
workspaceId: string;
@@ -30,15 +32,17 @@ type FormState = {
};
export function ContainerConfigTab({ workspaceId, data }: Props) {
const initial = useMemo(() => formFromData(data), [
data.runtime,
data.compute?.instance_type,
data.compute?.volume?.root_gb,
data.compute?.display?.mode,
data.compute?.display?.protocol,
data.compute?.display?.width,
data.compute?.display?.height,
]);
const runtime = data.runtime;
const instanceType = data.compute?.instance_type;
const rootGB = data.compute?.volume?.root_gb;
const displayMode = data.compute?.display?.mode;
const displayProtocol = data.compute?.display?.protocol;
const displayWidth = data.compute?.display?.width;
const displayHeight = data.compute?.display?.height;
const initial = useMemo(
() => formFromData({ runtime, instanceType, rootGB, displayMode, displayProtocol, displayWidth, displayHeight }),
[runtime, instanceType, rootGB, displayMode, displayProtocol, displayWidth, displayHeight],
);
const [form, setForm] = useState<FormState>(initial);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -219,18 +223,25 @@ export function ContainerConfigTab({ workspaceId, data }: Props) {
);
}
function formFromData(data: Props["data"]): FormState {
const display = data.compute?.display;
const width = display?.width ?? 1920;
const height = display?.height ?? 1080;
function formFromData(data: {
runtime?: string;
instanceType?: string;
rootGB?: number;
displayMode?: string;
displayProtocol?: string;
displayWidth?: number;
displayHeight?: number;
}): FormState {
const width = data.displayWidth ?? 1920;
const height = data.displayHeight ?? 1080;
const resolution = `${width}x${height}`;
return {
runtime: data.runtime || "claude-code",
instanceType: data.compute?.instance_type || "t3.large",
rootGB: String(data.compute?.volume?.root_gb || 50),
displayEnabled: !!display?.mode && display.mode !== "none",
displayMode: display?.mode && display.mode !== "none" ? display.mode : "desktop-control",
displayProtocol: display?.protocol || "novnc",
instanceType: data.instanceType || DEFAULT_HEADLESS_INSTANCE_TYPE,
rootGB: String(data.rootGB || DEFAULT_HEADLESS_ROOT_GB),
displayEnabled: !!data.displayMode && data.displayMode !== "none",
displayMode: data.displayMode && data.displayMode !== "none" ? data.displayMode : "desktop-control",
displayProtocol: data.displayProtocol || "novnc",
resolution,
};
}
@@ -36,6 +36,27 @@ beforeEach(() => {
});
describe("ContainerConfigTab", () => {
it("defaults missing compute to the cost-efficient headless profile", () => {
render(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 0,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
compute: undefined,
}}
/>,
);
expect(screen.getByLabelText("Instance type")).toHaveProperty("value", "t3.medium");
expect(screen.getByLabelText("Root volume")).toHaveProperty("value", "30");
});
it("renders persisted compute and status settings", () => {
render(
<ContainerConfigTab