From 70122ddac663c1689b4baa29e0afad718d307f50 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Sun, 14 Jun 2026 21:51:58 +0000 Subject: [PATCH 1/6] feat(canvas#2489): drive CreateWorkspaceDialog compute defaults from /compute/metadata SSOT The CreateWorkspaceDialog hardcoded DEFAULT_DISPLAY_INSTANCE_TYPE, DEFAULT_HEADLESS_INSTANCE_TYPE, CLOUD_PROVIDER_OPTIONS, and the display-mode instance-type dropdown. Replace them with reads from GET /compute/metadata, which now exposes display_default per provider (core#2489-A enabler merged). Changes: - New canvas/src/lib/compute-options.ts exports ComputeOptions, FALLBACK_COMPUTE_OPTIONS, parseComputeOptions, and provider helpers. ContainerConfigTab.tsx now imports from this shared module instead of duplicating the type/fallback/parser. - CreateWorkspaceDialog.tsx fetches /compute/metadata when the dialog opens, drives cloud-provider options from SSOT labels, headless default from defaults, display default from display_default, and the display instance-type dropdown from instanceTypes. Keeps FALLBACK_COMPUTE_OPTIONS for offline. - workspace-server ComputeMetadata now includes display_default per provider, so the canvas parser actually consumes the SSOT value instead of silently falling back to the bundled constant. - Add unit tests for compute-options parsing and a test verifying the display instance-type dropdown default is genuinely consumed from SSOT (not fallback). - Update CreateWorkspaceDialog.test.tsx fixtures and workspace-server tests to include display_default. Fixes #2489 --- .../src/components/CreateWorkspaceDialog.tsx | 86 ++++++++++++----- .../__tests__/CreateWorkspaceDialog.test.tsx | 92 ++++++++++++++++++ .../components/tabs/ContainerConfigTab.tsx | 73 +++----------- .../src/lib/__tests__/compute-options.test.ts | 82 ++++++++++++++++ canvas/src/lib/compute-options.ts | 96 +++++++++++++++++++ .../internal/handlers/workspace_compute.go | 3 + .../handlers/workspace_compute_test.go | 13 ++- 7 files changed, 356 insertions(+), 89 deletions(-) create mode 100644 canvas/src/lib/__tests__/compute-options.test.ts create mode 100644 canvas/src/lib/compute-options.ts diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 7d6dbdb9f..25383c4eb 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -3,6 +3,15 @@ import { useState, useEffect, useRef, useCallback, useId, useMemo } from "react"; import * as Dialog from "@radix-ui/react-dialog"; import { api } from "@/lib/api"; +import { + FALLBACK_COMPUTE_OPTIONS, + type ComputeOptions, + defaultInstanceForProvider, + displayDefaultForProvider, + instanceTypesForProvider, + parseComputeOptions, + providerLabel, +} from "@/lib/compute-options"; import { isSaaSTenant } from "@/lib/tenant"; import { ExternalConnectModal, type ExternalConnectionInfo } from "./ExternalConnectModal"; import { @@ -57,19 +66,7 @@ const RUNTIME_OPTIONS = [ { value: "openclaw", label: "OpenClaw" }, ]; const BASE_RUNTIME_TEMPLATE_IDS = new Set(["claude-code-default", "codex", "google-adk", "hermes", "openclaw"]); -const DEFAULT_HEADLESS_INSTANCE_TYPE = "t3.medium"; const DEFAULT_HEADLESS_ROOT_GB = 30; -const DEFAULT_DISPLAY_INSTANCE_TYPE = "t3.xlarge"; - -// Per-workspace cloud/compute backend (multi-provider RFC). "aws" is the default -// EC2 path; "gcp"/"hetzner" route to the matching CP WorkspaceProvisioner. A -// workspace whose cloud differs from its tenant's is reached over a per-workspace -// Cloudflare tunnel (runtime#95). Distinct from the LLM/model provider. -const CLOUD_PROVIDER_OPTIONS = [ - { value: "aws", label: "AWS (default)" }, - { value: "gcp", label: "GCP" }, - { value: "hetzner", label: "Hetzner" }, -]; const DEFAULT_DISPLAY_ROOT_GB = 80; export function CreateWorkspaceButton() { @@ -84,19 +81,35 @@ export function CreateWorkspaceButton() { const [error, setError] = useState(null); const [workspaces, setWorkspaces] = useState([]); const [displayEnabled, setDisplayEnabled] = useState(false); - const [displayInstanceType, setDisplayInstanceType] = useState(DEFAULT_DISPLAY_INSTANCE_TYPE); + const [displayInstanceType, setDisplayInstanceType] = useState( + displayDefaultForProvider(FALLBACK_COMPUTE_OPTIONS), + ); const [displayRootGB, setDisplayRootGB] = useState(String(DEFAULT_DISPLAY_ROOT_GB)); const [displayResolution, setDisplayResolution] = useState("1920x1080"); // Cloud/compute backend for the workspace box (multi-provider, per-workspace). // "aws" default; "gcp"/"hetzner" route to the matching CP WorkspaceProvisioner // (a non-tenant-cloud box is reached over a per-workspace tunnel, runtime#95). const [cloudProvider, setCloudProvider] = useState("aws"); + // SSOT provider + instance-type metadata from GET /compute/metadata. Starts from + // the offline fallback and is replaced once the fetch resolves; on fetch error we + // keep the fallback so the dialog stays usable. + const [computeOptions, setComputeOptions] = useState(FALLBACK_COMPUTE_OPTIONS); // Templates fetched from /api/templates — drives the dynamic provider // filter below. Same data source ConfigTab uses (PR #2454). When the // selected template declares `runtime_config.providers` in its // config.yaml, the modal surfaces only those providers in the // @@ -661,10 +700,11 @@ export function CreateWorkspaceButton() { onChange={(e) => setDisplayInstanceType(e.target.value)} className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-2 py-2 text-xs text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors" > - - - - + {instanceTypesForProvider(computeOptions, cloudProvider).map((it) => ( + + ))}
diff --git a/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx b/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx index 5e394f4e6..a73a38996 100644 --- a/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx +++ b/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx @@ -16,6 +16,32 @@ import { api } from "@/lib/api"; const mockGet = vi.mocked(api.get); const mockPost = vi.mocked(api.post); +const SAMPLE_COMPUTE_METADATA = { + providers: [ + { + id: "aws", + label: "AWS (default)", + default_instance: "t3.medium", + display_default: "t3.xlarge", + instances: ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge", "m6i.large", "m6i.xlarge", "c6i.xlarge"], + }, + { + id: "hetzner", + label: "Hetzner", + default_instance: "cpx31", + display_default: "cpx41", + instances: ["cpx11", "cpx21", "cpx31", "cpx41", "cpx51", "cax11", "cax21", "cax31", "cax41"], + }, + { + id: "gcp", + label: "GCP", + default_instance: "e2-standard-2", + display_default: "e2-standard-4", + instances: ["e2-small", "e2-medium", "e2-standard-2", "e2-standard-4", "e2-standard-8"], + }, + ], +}; + const SAMPLE_WORKSPACES = [ { id: "ws-1", name: "Platform Team", tier: 1 }, { id: "ws-2", name: "Research Agent", tier: 2 }, @@ -103,6 +129,10 @@ beforeEach(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return SAMPLE_TEMPLATES as any; } + if (url === "/compute/metadata") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return SAMPLE_COMPUTE_METADATA as any; + } // eslint-disable-next-line @typescript-eslint/no-explicit-any return SAMPLE_WORKSPACES as any; }); @@ -298,6 +328,68 @@ describe("CreateWorkspaceDialog", () => { }); }); + it("drives display instance-type options from /compute/metadata SSOT", async () => { + await openDialog(); + fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { + target: { value: "Desktop Agent" }, + }); + fireEvent.click(screen.getByLabelText("Enable display")); + + const instanceSelect = screen.getByLabelText("Instance") as HTMLSelectElement; + await waitFor(() => { + const optionValues = Array.from(instanceSelect.options).map((o) => o.value); + expect(optionValues).toEqual(SAMPLE_COMPUTE_METADATA.providers[0].instances); + }); + }); + + it("consumes the SSOT display default instead of the in-bundle fallback", async () => { + // Override the /compute/metadata mock so AWS display_default differs from the + // bundled FALLBACK_COMPUTE_OPTIONS. This proves the dialog reads the live SSOT + // value and does not silently fall back to the offline bundle. + mockGet.mockImplementation(async (url: string) => { + if (url === "/compute/metadata") { + return { + providers: [ + { + id: "aws", + label: "AWS (default)", + default_instance: "t3.medium", + display_default: "t3.2xlarge", + instances: ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge"], + }, + ], + }; + } + if (url === "/templates") return SAMPLE_TEMPLATES as any; + return SAMPLE_WORKSPACES as any; + }); + + await openDialog(); + fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { + target: { value: "SSOT Display Agent" }, + }); + fireEvent.click(screen.getByLabelText("Enable display")); + + const instanceSelect = screen.getByLabelText("Instance") as HTMLSelectElement; + await waitFor(() => expect(instanceSelect.value).toBe("t3.2xlarge")); + + 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; + expect(body.compute).toEqual({ + instance_type: "t3.2xlarge", + volume: { root_gb: 80 }, + display: { + mode: "desktop-control", + protocol: "novnc", + width: 1920, + height: 1080, + }, + }); + }); + it("sends BYOK API key secrets when API key auth mode is selected", async () => { await openDialog(); fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { diff --git a/canvas/src/components/tabs/ContainerConfigTab.tsx b/canvas/src/components/tabs/ContainerConfigTab.tsx index 481ad0d8d..ee62a55ea 100644 --- a/canvas/src/components/tabs/ContainerConfigTab.tsx +++ b/canvas/src/components/tabs/ContainerConfigTab.tsx @@ -2,49 +2,19 @@ import { useEffect, useMemo, useState } from "react"; import { api } from "@/lib/api"; +import { + FALLBACK_COMPUTE_OPTIONS, + type ComputeOptions, + defaultInstanceForProvider, + instanceTypesForProvider, + normalizeProvider, + parseComputeOptions, +} from "@/lib/compute-options"; import { runtimeDisplayName } from "@/lib/runtime-names"; import { isSaaSTenant } from "@/lib/tenant"; import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; import type { WorkspaceCompute } from "@/store/socket"; -// Cloud-provider + instance-type metadata (core#2489). -// -// SSOT lives in the workspace-server (workspace_compute.go's allowlist + defaults) -// and is fetched at runtime from GET /compute/metadata (public, workspace- -// independent endpoint — the data is platform constraints, not org secrets), so -// the UI can never offer a (provider, instance-type) the PATCH validation then -// rejects with a 400. The constants below are ONLY a minimal offline fallback -// used until the fetch resolves (or if it fails) — they mirror the server SSOT -// but are not the source of truth. When the fetch succeeds, its data replaces -// them entirely. -// -// Response shape (workspace-server): -// { providers: [{ id: "aws", label: "AWS (default)", default_instance: "t3.medium", -// instances: ["t3.medium", ...] }, ...] } -type ComputeOptions = { - providers: string[]; - instanceTypes: Record; - defaults: Record; - labels: Record; -}; - -const FALLBACK_COMPUTE_OPTIONS: ComputeOptions = { - providers: ["aws", "hetzner", "gcp"], - instanceTypes: { - aws: ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge", "m6i.large", "m6i.xlarge", "c6i.xlarge"], - hetzner: ["cpx11", "cpx21", "cpx31", "cpx41", "cpx51", "cax11", "cax21", "cax31", "cax41"], - gcp: ["e2-small", "e2-medium", "e2-standard-2", "e2-standard-4", "e2-standard-8"], - }, - defaults: { aws: "t3.medium", hetzner: "cpx31", gcp: "e2-standard-2" }, - labels: { aws: "AWS (default)", gcp: "GCP", hetzner: "Hetzner" }, -}; - -const normalizeProvider = (p?: string): string => (p === "gcp" || p === "hetzner" ? p : "aws"); -const instanceTypesForProvider = (opts: ComputeOptions, p?: string): string[] => - opts.instanceTypes[normalizeProvider(p)] ?? opts.instanceTypes.aws ?? FALLBACK_COMPUTE_OPTIONS.instanceTypes.aws; -const defaultInstanceForProvider = (opts: ComputeOptions, p?: string): string => - opts.defaults[normalizeProvider(p)] ?? "t3.medium"; - const RUNTIME_OPTIONS = ["claude-code", "codex", "hermes", "openclaw", "kimi", "kimi-cli", "external"]; const RESOLUTIONS = ["1280x720", "1440x900", "1920x1080", "2560x1440"]; const DEFAULT_HEADLESS_ROOT_GB = 30; @@ -121,33 +91,14 @@ export function ContainerConfigTab({ workspaceId, data }: Props) { // /compute/metadata is a public, workspace-independent endpoint (the data // is platform constraints, not org secrets) — no need to refetch on // workspaceId change; one fetch per tab mount is enough. - const resp = await api.get<{ - providers?: Array<{ id: string; label?: string; default_instance?: string; instances?: string[] }>; - }>("/compute/metadata"); + const resp = await api.get("/compute/metadata"); if (cancelled) return; // Defensive: only adopt a well-formed payload; otherwise keep the fallback. // Map the server's per-provider object shape into the flat internal // ComputeOptions shape the helpers + selectors consume. - if (resp && Array.isArray(resp.providers) && resp.providers.length > 0) { - const providers: string[] = []; - const instanceTypes: Record = {}; - const defaults: Record = {}; - const labels: Record = {}; - for (const p of resp.providers) { - if (!p || typeof p.id !== "string" || !p.id) continue; - providers.push(p.id); - if (Array.isArray(p.instances) && p.instances.length > 0) instanceTypes[p.id] = p.instances; - if (typeof p.default_instance === "string" && p.default_instance) defaults[p.id] = p.default_instance; - if (typeof p.label === "string" && p.label) labels[p.id] = p.label; - } - if (providers.length > 0) { - setComputeOptions({ - providers, - instanceTypes: Object.keys(instanceTypes).length > 0 ? instanceTypes : FALLBACK_COMPUTE_OPTIONS.instanceTypes, - defaults: Object.keys(defaults).length > 0 ? defaults : FALLBACK_COMPUTE_OPTIONS.defaults, - labels: Object.keys(labels).length > 0 ? labels : FALLBACK_COMPUTE_OPTIONS.labels, - }); - } + const parsed = parseComputeOptions(resp); + if (parsed) { + setComputeOptions(parsed); } } catch { // Fetch failed (offline / older server) — keep FALLBACK_COMPUTE_OPTIONS. diff --git a/canvas/src/lib/__tests__/compute-options.test.ts b/canvas/src/lib/__tests__/compute-options.test.ts new file mode 100644 index 000000000..79ade0228 --- /dev/null +++ b/canvas/src/lib/__tests__/compute-options.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { + parseComputeOptions, + FALLBACK_COMPUTE_OPTIONS, + defaultInstanceForProvider, + displayDefaultForProvider, + instanceTypesForProvider, + normalizeProvider, + providerLabel, +} from "../compute-options"; + +describe("compute-options", () => { + const serverResponse = { + providers: [ + { + id: "aws", + label: "AWS (default)", + default_instance: "t3.medium", + display_default: "t3.xlarge", + instances: ["t3.medium", "t3.large", "t3.xlarge"], + }, + { + id: "hetzner", + label: "Hetzner", + default_instance: "cpx31", + display_default: "cpx41", + instances: ["cpx31", "cpx41"], + }, + { + id: "gcp", + label: "GCP", + default_instance: "e2-standard-2", + display_default: "e2-standard-4", + instances: ["e2-standard-2", "e2-standard-4"], + }, + ], + }; + + it("parses /compute/metadata into ComputeOptions", () => { + const opts = parseComputeOptions(serverResponse); + expect(opts).not.toBeNull(); + expect(opts?.providers).toEqual(["aws", "hetzner", "gcp"]); + expect(opts?.defaults).toEqual({ aws: "t3.medium", hetzner: "cpx31", gcp: "e2-standard-2" }); + expect(opts?.displayDefaults).toEqual({ + aws: "t3.xlarge", + hetzner: "cpx41", + gcp: "e2-standard-4", + }); + expect(opts?.labels).toEqual({ aws: "AWS (default)", hetzner: "Hetzner", gcp: "GCP" }); + expect(opts?.instanceTypes.aws).toEqual(["t3.medium", "t3.large", "t3.xlarge"]); + }); + + it("falls back to in-bundle defaults when display_default is missing", () => { + const partial = { + providers: [ + { + id: "aws", + default_instance: "t3.medium", + instances: ["t3.medium"], + }, + ], + }; + const opts = parseComputeOptions(partial); + expect(opts?.displayDefaults).toEqual(FALLBACK_COMPUTE_OPTIONS.displayDefaults); + }); + + it("returns null for malformed payloads", () => { + expect(parseComputeOptions(null)).toBeNull(); + expect(parseComputeOptions({})).toBeNull(); + expect(parseComputeOptions({ providers: [] })).toBeNull(); + expect(parseComputeOptions({ providers: [{ id: "" }] })).toBeNull(); + }); + + it("helpers resolve per-provider values", () => { + const opts = parseComputeOptions(serverResponse)!; + expect(defaultInstanceForProvider(opts, "aws")).toBe("t3.medium"); + expect(displayDefaultForProvider(opts, "aws")).toBe("t3.xlarge"); + expect(instanceTypesForProvider(opts, "hetzner")).toEqual(["cpx31", "cpx41"]); + expect(providerLabel(opts, "gcp")).toBe("GCP"); + expect(normalizeProvider(undefined)).toBe("aws"); + }); +}); diff --git a/canvas/src/lib/compute-options.ts b/canvas/src/lib/compute-options.ts new file mode 100644 index 000000000..1049f3cd2 --- /dev/null +++ b/canvas/src/lib/compute-options.ts @@ -0,0 +1,96 @@ +// Cloud-provider + instance-type metadata (core#2489). +// +// SSOT lives in the workspace-server (workspace_compute.go's allowlist + defaults) +// and is fetched at runtime from GET /compute/metadata (public, workspace- +// independent endpoint — the data is platform constraints, not org secrets), so +// the UI can never offer a (provider, instance-type) the PATCH validation then +// rejects with a 400. The constants below are ONLY a minimal offline fallback +// used until the fetch resolves (or if it fails) — they mirror the server SSOT +// but are not the source of truth. When the fetch succeeds, its data replaces +// them entirely. +// +// Response shape (workspace-server): +// { providers: [{ id: "aws", label: "AWS (default)", default_instance: "t3.medium", +// instances: ["t3.medium", ...], +// display_default: "t3.xlarge" }, ...] } + +export type ComputeOptions = { + providers: string[]; + instanceTypes: Record; + defaults: Record; + displayDefaults: Record; + labels: Record; +}; + +export const FALLBACK_COMPUTE_OPTIONS: ComputeOptions = { + providers: ["aws", "hetzner", "gcp"], + instanceTypes: { + aws: ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge", "m6i.large", "m6i.xlarge", "c6i.xlarge"], + hetzner: ["cpx11", "cpx21", "cpx31", "cpx41", "cpx51", "cax11", "cax21", "cax31", "cax41"], + gcp: ["e2-small", "e2-medium", "e2-standard-2", "e2-standard-4", "e2-standard-8"], + }, + defaults: { aws: "t3.medium", hetzner: "cpx31", gcp: "e2-standard-2" }, + displayDefaults: { aws: "t3.xlarge", hetzner: "cpx41", gcp: "e2-standard-4" }, + labels: { aws: "AWS (default)", gcp: "GCP", hetzner: "Hetzner" }, +}; + +export const normalizeProvider = (p?: string): string => + p === "gcp" || p === "hetzner" ? p : "aws"; + +export const instanceTypesForProvider = (opts: ComputeOptions, p?: string): string[] => + opts.instanceTypes[normalizeProvider(p)] ?? + opts.instanceTypes.aws ?? + FALLBACK_COMPUTE_OPTIONS.instanceTypes.aws; + +export const defaultInstanceForProvider = (opts: ComputeOptions, p?: string): string => + opts.defaults[normalizeProvider(p)] ?? FALLBACK_COMPUTE_OPTIONS.defaults.aws; + +export const displayDefaultForProvider = (opts: ComputeOptions, p?: string): string => + opts.displayDefaults[normalizeProvider(p)] ?? FALLBACK_COMPUTE_OPTIONS.displayDefaults.aws; + +export const providerLabel = (opts: ComputeOptions, p?: string): string => + opts.labels[normalizeProvider(p)] ?? FALLBACK_COMPUTE_OPTIONS.labels.aws; + +// Build ComputeOptions from the workspace-server /compute/metadata response. +// Returns null when the payload is not well-formed, so callers can keep the +// fallback. +export function parseComputeOptions(resp: unknown): ComputeOptions | null { + if (!resp || typeof resp !== "object") return null; + const { providers } = resp as { providers?: unknown }; + if (!Array.isArray(providers) || providers.length === 0) return null; + + const providerIds: string[] = []; + const instanceTypes: Record = {}; + const defaults: Record = {}; + const displayDefaults: Record = {}; + const labels: Record = {}; + + for (const p of providers) { + if (!p || typeof p !== "object") continue; + const { id, label, default_instance, display_default, instances } = p as { + id?: unknown; + label?: unknown; + default_instance?: unknown; + display_default?: unknown; + instances?: unknown; + }; + if (typeof id !== "string" || !id) continue; + providerIds.push(id); + if (typeof label === "string" && label) labels[id] = label; + if (typeof default_instance === "string" && default_instance) defaults[id] = default_instance; + if (typeof display_default === "string" && display_default) displayDefaults[id] = display_default; + if (Array.isArray(instances) && instances.length > 0) { + instanceTypes[id] = instances.filter((i): i is string => typeof i === "string" && Boolean(i)); + } + } + + if (providerIds.length === 0) return null; + + return { + providers: providerIds, + instanceTypes: Object.keys(instanceTypes).length > 0 ? instanceTypes : FALLBACK_COMPUTE_OPTIONS.instanceTypes, + defaults: Object.keys(defaults).length > 0 ? defaults : FALLBACK_COMPUTE_OPTIONS.defaults, + displayDefaults: Object.keys(displayDefaults).length > 0 ? displayDefaults : FALLBACK_COMPUTE_OPTIONS.displayDefaults, + labels: Object.keys(labels).length > 0 ? labels : FALLBACK_COMPUTE_OPTIONS.labels, + }; +} diff --git a/workspace-server/internal/handlers/workspace_compute.go b/workspace-server/internal/handlers/workspace_compute.go index 72dd3abed..59cef56ef 100644 --- a/workspace-server/internal/handlers/workspace_compute.go +++ b/workspace-server/internal/handlers/workspace_compute.go @@ -403,6 +403,7 @@ type computeProviderMetadata struct { ID string `json:"id"` Label string `json:"label"` DefaultInstance string `json:"default_instance"` + DisplayDefault string `json:"display_default"` Instances []string `json:"instances"` } @@ -442,11 +443,13 @@ func ComputeMetadata(c *gin.Context) { // endpoint to surface that as a panic at boot, not as // a silent empty render. defaultInstance := workspaceComputeDefaultInstanceByProvider[id] + displayDefault := workspaceComputeDisplayDefaultByProvider[id] instances := workspaceComputeInstanceTypesOrdered[id] providers = append(providers, computeProviderMetadata{ ID: id, Label: label, DefaultInstance: defaultInstance, + DisplayDefault: displayDefault, Instances: instances, }) } diff --git a/workspace-server/internal/handlers/workspace_compute_test.go b/workspace-server/internal/handlers/workspace_compute_test.go index 4b7c13646..90c61a6f3 100644 --- a/workspace-server/internal/handlers/workspace_compute_test.go +++ b/workspace-server/internal/handlers/workspace_compute_test.go @@ -846,12 +846,12 @@ func TestComputeMetadata_ReturnsProviderAllowlist(t *testing.T) { t.Fatalf("expected 3 providers, got %d", len(resp.Providers)) } want := []struct { - id, label, defaultInstance string - instanceCount int + id, label, defaultInstance, displayDefault string + instanceCount int }{ - {"aws", "AWS (default)", "t3.medium", 7}, - {"gcp", "GCP", "e2-standard-2", 5}, - {"hetzner", "Hetzner", "cpx31", 9}, + {"aws", "AWS (default)", "t3.medium", "t3.xlarge", 7}, + {"gcp", "GCP", "e2-standard-2", "e2-standard-4", 5}, + {"hetzner", "Hetzner", "cpx31", "cpx41", 9}, } for i, w := range want { p := resp.Providers[i] @@ -864,6 +864,9 @@ func TestComputeMetadata_ReturnsProviderAllowlist(t *testing.T) { if p.DefaultInstance != w.defaultInstance { t.Errorf("providers[%d].default_instance = %q, want %q", i, p.DefaultInstance, w.defaultInstance) } + if p.DisplayDefault != w.displayDefault { + t.Errorf("providers[%d].display_default = %q, want %q", i, p.DisplayDefault, w.displayDefault) + } if len(p.Instances) != w.instanceCount { t.Errorf("providers[%d].instances len = %d, want %d", i, len(p.Instances), w.instanceCount) } -- 2.52.0 From d8d896700448bab165948254a831efe4564096c1 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Sun, 14 Jun 2026 22:29:52 +0000 Subject: [PATCH 2/6] feat(canvas#2489): consume top-level display_defaults from /compute/metadata - Switch /compute/metadata response to workspaceComputeOptionsResponse shape (providers / instanceTypes / defaults / display_defaults), exposing the display-default SSOT added in #2879 as a top-level map. - Update ComputeOptions type + parser to read the new top-level maps; drop the per-provider object parsing and the labels field (labels are UI-only chrome and now live next to their consumers). - CreateWorkspaceDialog: replace providerLabel(computeOptions, p) usage with a local provider label map; drive display instance type from displayDefaultForProvider(computeOptions, cloudProvider). - ContainerConfigTab: use cloudProviderLabel for the provider dropdown labels now that ComputeOptions no longer carries labels. - Update unit-test fixtures in compute-options.test.ts, CreateWorkspaceDialog and ContainerConfigTab tests to the new /compute/metadata shape. All canvas tests pass (3486) and next build succeeds. Co-Authored-By: Claude --- .../src/components/CreateWorkspaceDialog.tsx | 14 ++- .../__tests__/CreateWorkspaceDialog.test.tsx | 54 +++++------- .../components/tabs/ContainerConfigTab.tsx | 2 +- .../__tests__/ContainerConfigTab.test.tsx | 27 ++++-- .../src/lib/__tests__/compute-options.test.ts | 59 +++++-------- canvas/src/lib/compute-options.ts | 85 +++++++++++-------- .../internal/handlers/workspace_compute.go | 52 ++++-------- .../handlers/workspace_compute_test.go | 33 ++++--- .../router/compute_metadata_route_test.go | 51 ++++++----- 9 files changed, 179 insertions(+), 198 deletions(-) diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 25383c4eb..00c96e5e8 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -10,7 +10,6 @@ import { displayDefaultForProvider, instanceTypesForProvider, parseComputeOptions, - providerLabel, } from "@/lib/compute-options"; import { isSaaSTenant } from "@/lib/tenant"; import { ExternalConnectModal, type ExternalConnectionInfo } from "./ExternalConnectModal"; @@ -58,6 +57,17 @@ interface TemplateSpec { } const DEFAULT_RUNTIME = "claude-code"; + +// Human-readable labels for cloud-provider IDs. Provider ordering and +// instance-type allowlists are SSOT from GET /compute/metadata; labels are +// pure UI chrome and live next to the only consumer (#2489). +const PROVIDER_LABELS: Record = { + aws: "AWS (default)", + gcp: "GCP", + hetzner: "Hetzner", +}; +const providerLabel = (id: string): string => PROVIDER_LABELS[id] ?? id; + const RUNTIME_OPTIONS = [ { value: "claude-code", label: "Claude Code" }, { value: "codex", label: "OpenAI Codex CLI" }, @@ -672,7 +682,7 @@ export function CreateWorkspaceButton() { > {computeOptions.providers.map((p) => ( ))} diff --git a/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx b/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx index a73a38996..04b51b1c4 100644 --- a/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx +++ b/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx @@ -17,29 +17,22 @@ const mockGet = vi.mocked(api.get); const mockPost = vi.mocked(api.post); const SAMPLE_COMPUTE_METADATA = { - providers: [ - { - id: "aws", - label: "AWS (default)", - default_instance: "t3.medium", - display_default: "t3.xlarge", - instances: ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge", "m6i.large", "m6i.xlarge", "c6i.xlarge"], - }, - { - id: "hetzner", - label: "Hetzner", - default_instance: "cpx31", - display_default: "cpx41", - instances: ["cpx11", "cpx21", "cpx31", "cpx41", "cpx51", "cax11", "cax21", "cax31", "cax41"], - }, - { - id: "gcp", - label: "GCP", - default_instance: "e2-standard-2", - display_default: "e2-standard-4", - instances: ["e2-small", "e2-medium", "e2-standard-2", "e2-standard-4", "e2-standard-8"], - }, - ], + providers: ["aws", "hetzner", "gcp"], + instanceTypes: { + aws: ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge", "m6i.large", "m6i.xlarge", "c6i.xlarge"], + hetzner: ["cpx11", "cpx21", "cpx31", "cpx41", "cpx51", "cax11", "cax21", "cax31", "cax41"], + gcp: ["e2-small", "e2-medium", "e2-standard-2", "e2-standard-4", "e2-standard-8"], + }, + defaults: { + aws: "t3.medium", + hetzner: "cpx31", + gcp: "e2-standard-2", + }, + display_defaults: { + aws: "t3.xlarge", + hetzner: "cpx41", + gcp: "e2-standard-4", + }, }; const SAMPLE_WORKSPACES = [ @@ -338,7 +331,7 @@ describe("CreateWorkspaceDialog", () => { const instanceSelect = screen.getByLabelText("Instance") as HTMLSelectElement; await waitFor(() => { const optionValues = Array.from(instanceSelect.options).map((o) => o.value); - expect(optionValues).toEqual(SAMPLE_COMPUTE_METADATA.providers[0].instances); + expect(optionValues).toEqual(SAMPLE_COMPUTE_METADATA.instanceTypes.aws); }); }); @@ -349,15 +342,10 @@ describe("CreateWorkspaceDialog", () => { mockGet.mockImplementation(async (url: string) => { if (url === "/compute/metadata") { return { - providers: [ - { - id: "aws", - label: "AWS (default)", - default_instance: "t3.medium", - display_default: "t3.2xlarge", - instances: ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge"], - }, - ], + providers: ["aws"], + instanceTypes: { aws: ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge"] }, + defaults: { aws: "t3.medium" }, + display_defaults: { aws: "t3.2xlarge" }, }; } if (url === "/templates") return SAMPLE_TEMPLATES as any; diff --git a/canvas/src/components/tabs/ContainerConfigTab.tsx b/canvas/src/components/tabs/ContainerConfigTab.tsx index ee62a55ea..8d868a3ca 100644 --- a/canvas/src/components/tabs/ContainerConfigTab.tsx +++ b/canvas/src/components/tabs/ContainerConfigTab.tsx @@ -225,7 +225,7 @@ export function ContainerConfigTab({ workspaceId, data }: Props) { label="Cloud provider" value={normalizeProvider(form.provider)} options={computeOptions.providers} - optionLabel={(v) => computeOptions.labels[v] ?? v} + optionLabel={(v) => cloudProviderLabel(v)} // Switching cloud resets the instance type to the new provider's // default (an AWS t3.* is invalid on Hetzner, etc.) — also keeps the // instance-type dropdown below in sync with the provider's sizes. diff --git a/canvas/src/components/tabs/__tests__/ContainerConfigTab.test.tsx b/canvas/src/components/tabs/__tests__/ContainerConfigTab.test.tsx index 6c54b8538..f1e4f6129 100644 --- a/canvas/src/components/tabs/__tests__/ContainerConfigTab.test.tsx +++ b/canvas/src/components/tabs/__tests__/ContainerConfigTab.test.tsx @@ -372,14 +372,22 @@ describe("ContainerConfigTab", () => { // dropdowns: a server-only instance type appears once the fetch resolves. it("populates instance-type options from the /compute/metadata SSOT endpoint", async () => { apiGet.mockResolvedValueOnce({ - providers: [ - // Real server response shape: { id, label, default_instance, instances }. - // The "z9.future" instance is server-only — the in-bundle fallback doesn't - // list it; once the fetch resolves, it appears in the dropdown. - { id: "aws", label: "AWS (default)", default_instance: "t3.medium", instances: ["t3.medium", "t3.large", "z9.future"] }, - { id: "hetzner", label: "Hetzner", default_instance: "cpx31", instances: ["cpx31"] }, - { id: "gcp", label: "GCP", default_instance: "e2-standard-2", instances: ["e2-standard-2"] }, - ], + providers: ["aws", "hetzner", "gcp"], + instanceTypes: { + aws: ["t3.medium", "t3.large", "z9.future"], + hetzner: ["cpx31"], + gcp: ["e2-standard-2"], + }, + defaults: { + aws: "t3.medium", + hetzner: "cpx31", + gcp: "e2-standard-2", + }, + display_defaults: { + aws: "t3.xlarge", + hetzner: "cpx41", + gcp: "e2-standard-4", + }, }); render( @@ -458,7 +466,8 @@ describe("ContainerConfigTab", () => { // gcp instances (5): e2-small, e2-medium, e2-standard-2, // e2-standard-4, e2-standard-8 // gcp default: e2-standard-2 - // labels: aws="AWS (default)", gcp="GCP", hetzner="Hetzner" + // display defaults: aws="t3.xlarge", gcp="e2-standard-4", + // hetzner="cpx41" // // The test exercises the fallback path by making the live fetch // fail; the assertions then read what the dropdowns actually diff --git a/canvas/src/lib/__tests__/compute-options.test.ts b/canvas/src/lib/__tests__/compute-options.test.ts index 79ade0228..1a5d77f52 100644 --- a/canvas/src/lib/__tests__/compute-options.test.ts +++ b/canvas/src/lib/__tests__/compute-options.test.ts @@ -1,39 +1,30 @@ import { describe, it, expect } from "vitest"; import { parseComputeOptions, - FALLBACK_COMPUTE_OPTIONS, defaultInstanceForProvider, displayDefaultForProvider, instanceTypesForProvider, normalizeProvider, - providerLabel, } from "../compute-options"; describe("compute-options", () => { const serverResponse = { - providers: [ - { - id: "aws", - label: "AWS (default)", - default_instance: "t3.medium", - display_default: "t3.xlarge", - instances: ["t3.medium", "t3.large", "t3.xlarge"], - }, - { - id: "hetzner", - label: "Hetzner", - default_instance: "cpx31", - display_default: "cpx41", - instances: ["cpx31", "cpx41"], - }, - { - id: "gcp", - label: "GCP", - default_instance: "e2-standard-2", - display_default: "e2-standard-4", - instances: ["e2-standard-2", "e2-standard-4"], - }, - ], + providers: ["aws", "hetzner", "gcp"], + instanceTypes: { + aws: ["t3.medium", "t3.large", "t3.xlarge"], + hetzner: ["cpx31", "cpx41"], + gcp: ["e2-standard-2", "e2-standard-4"], + }, + defaults: { + aws: "t3.medium", + hetzner: "cpx31", + gcp: "e2-standard-2", + }, + display_defaults: { + aws: "t3.xlarge", + hetzner: "cpx41", + gcp: "e2-standard-4", + }, }; it("parses /compute/metadata into ComputeOptions", () => { @@ -46,29 +37,24 @@ describe("compute-options", () => { hetzner: "cpx41", gcp: "e2-standard-4", }); - expect(opts?.labels).toEqual({ aws: "AWS (default)", hetzner: "Hetzner", gcp: "GCP" }); expect(opts?.instanceTypes.aws).toEqual(["t3.medium", "t3.large", "t3.xlarge"]); }); - it("falls back to in-bundle defaults when display_default is missing", () => { + it("falls back to in-bundle defaults when display_defaults is missing", () => { const partial = { - providers: [ - { - id: "aws", - default_instance: "t3.medium", - instances: ["t3.medium"], - }, - ], + providers: ["aws"], + instanceTypes: { aws: ["t3.medium"] }, + defaults: { aws: "t3.medium" }, }; const opts = parseComputeOptions(partial); - expect(opts?.displayDefaults).toEqual(FALLBACK_COMPUTE_OPTIONS.displayDefaults); + expect(opts).toBeNull(); }); it("returns null for malformed payloads", () => { expect(parseComputeOptions(null)).toBeNull(); expect(parseComputeOptions({})).toBeNull(); expect(parseComputeOptions({ providers: [] })).toBeNull(); - expect(parseComputeOptions({ providers: [{ id: "" }] })).toBeNull(); + expect(parseComputeOptions({ providers: [""], instanceTypes: {}, defaults: {}, display_defaults: {} })).toBeNull(); }); it("helpers resolve per-provider values", () => { @@ -76,7 +62,6 @@ describe("compute-options", () => { expect(defaultInstanceForProvider(opts, "aws")).toBe("t3.medium"); expect(displayDefaultForProvider(opts, "aws")).toBe("t3.xlarge"); expect(instanceTypesForProvider(opts, "hetzner")).toEqual(["cpx31", "cpx41"]); - expect(providerLabel(opts, "gcp")).toBe("GCP"); expect(normalizeProvider(undefined)).toBe("aws"); }); }); diff --git a/canvas/src/lib/compute-options.ts b/canvas/src/lib/compute-options.ts index 1049f3cd2..f88144f83 100644 --- a/canvas/src/lib/compute-options.ts +++ b/canvas/src/lib/compute-options.ts @@ -1,6 +1,6 @@ // Cloud-provider + instance-type metadata (core#2489). // -// SSOT lives in the workspace-server (workspace_compute.go's allowlist + defaults) +// SSOT lives in the workspace-server (workspace_compute.go allowlist + defaults) // and is fetched at runtime from GET /compute/metadata (public, workspace- // independent endpoint — the data is platform constraints, not org secrets), so // the UI can never offer a (provider, instance-type) the PATCH validation then @@ -9,17 +9,19 @@ // but are not the source of truth. When the fetch succeeds, its data replaces // them entirely. // -// Response shape (workspace-server): -// { providers: [{ id: "aws", label: "AWS (default)", default_instance: "t3.medium", -// instances: ["t3.medium", ...], -// display_default: "t3.xlarge" }, ...] } +// Response shape (workspace-server GET /compute/metadata): +// { +// providers: ["aws", "gcp", "hetzner"], +// instanceTypes: { aws: ["t3.medium", ...], ... }, +// defaults: { aws: "t3.medium", ... }, +// display_defaults: { aws: "t3.xlarge", ... } +// } export type ComputeOptions = { providers: string[]; instanceTypes: Record; defaults: Record; displayDefaults: Record; - labels: Record; }; export const FALLBACK_COMPUTE_OPTIONS: ComputeOptions = { @@ -31,7 +33,6 @@ export const FALLBACK_COMPUTE_OPTIONS: ComputeOptions = { }, defaults: { aws: "t3.medium", hetzner: "cpx31", gcp: "e2-standard-2" }, displayDefaults: { aws: "t3.xlarge", hetzner: "cpx41", gcp: "e2-standard-4" }, - labels: { aws: "AWS (default)", gcp: "GCP", hetzner: "Hetzner" }, }; export const normalizeProvider = (p?: string): string => @@ -48,49 +49,59 @@ export const defaultInstanceForProvider = (opts: ComputeOptions, p?: string): st export const displayDefaultForProvider = (opts: ComputeOptions, p?: string): string => opts.displayDefaults[normalizeProvider(p)] ?? FALLBACK_COMPUTE_OPTIONS.displayDefaults.aws; -export const providerLabel = (opts: ComputeOptions, p?: string): string => - opts.labels[normalizeProvider(p)] ?? FALLBACK_COMPUTE_OPTIONS.labels.aws; - // Build ComputeOptions from the workspace-server /compute/metadata response. // Returns null when the payload is not well-formed, so callers can keep the // fallback. export function parseComputeOptions(resp: unknown): ComputeOptions | null { if (!resp || typeof resp !== "object") return null; - const { providers } = resp as { providers?: unknown }; + const { + providers, + instanceTypes, + defaults, + display_defaults, + } = resp as { + providers?: unknown; + instanceTypes?: unknown; + defaults?: unknown; + display_defaults?: unknown; + }; + if (!Array.isArray(providers) || providers.length === 0) return null; + if (!instanceTypes || typeof instanceTypes !== "object") return null; + if (!defaults || typeof defaults !== "object") return null; + if (!display_defaults || typeof display_defaults !== "object") return null; const providerIds: string[] = []; - const instanceTypes: Record = {}; - const defaults: Record = {}; - const displayDefaults: Record = {}; - const labels: Record = {}; - for (const p of providers) { - if (!p || typeof p !== "object") continue; - const { id, label, default_instance, display_default, instances } = p as { - id?: unknown; - label?: unknown; - default_instance?: unknown; - display_default?: unknown; - instances?: unknown; - }; - if (typeof id !== "string" || !id) continue; - providerIds.push(id); - if (typeof label === "string" && label) labels[id] = label; - if (typeof default_instance === "string" && default_instance) defaults[id] = default_instance; - if (typeof display_default === "string" && display_default) displayDefaults[id] = display_default; - if (Array.isArray(instances) && instances.length > 0) { - instanceTypes[id] = instances.filter((i): i is string => typeof i === "string" && Boolean(i)); - } + if (typeof p === "string" && p) providerIds.push(p); } - if (providerIds.length === 0) return null; + const pickStrings = (obj: unknown): Record => { + if (!obj || typeof obj !== "object") return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(obj as Record)) { + if (typeof v === "string" && v) out[k] = v; + } + return out; + }; + + const pickStringArrays = (obj: unknown): Record => { + if (!obj || typeof obj !== "object") return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(obj as Record)) { + if (Array.isArray(v)) { + const filtered = v.filter((item): item is string => typeof item === "string" && Boolean(item)); + if (filtered.length > 0) out[k] = filtered; + } + } + return out; + }; + return { providers: providerIds, - instanceTypes: Object.keys(instanceTypes).length > 0 ? instanceTypes : FALLBACK_COMPUTE_OPTIONS.instanceTypes, - defaults: Object.keys(defaults).length > 0 ? defaults : FALLBACK_COMPUTE_OPTIONS.defaults, - displayDefaults: Object.keys(displayDefaults).length > 0 ? displayDefaults : FALLBACK_COMPUTE_OPTIONS.displayDefaults, - labels: Object.keys(labels).length > 0 ? labels : FALLBACK_COMPUTE_OPTIONS.labels, + instanceTypes: pickStringArrays(instanceTypes), + defaults: pickStrings(defaults), + displayDefaults: pickStrings(display_defaults), }; } diff --git a/workspace-server/internal/handlers/workspace_compute.go b/workspace-server/internal/handlers/workspace_compute.go index 59cef56ef..6037a23aa 100644 --- a/workspace-server/internal/handlers/workspace_compute.go +++ b/workspace-server/internal/handlers/workspace_compute.go @@ -399,18 +399,6 @@ func validateWorkspaceDisplayDimensions(width, height int) error { return nil } -type computeProviderMetadata struct { - ID string `json:"id"` - Label string `json:"label"` - DefaultInstance string `json:"default_instance"` - DisplayDefault string `json:"display_default"` - Instances []string `json:"instances"` -} - -type computeMetadataResponse struct { - Providers []computeProviderMetadata `json:"providers"` -} - // ComputeMetadata handles GET /compute/metadata — SSOT for cloud-provider + // instance-type allowlists consumed by the canvas ContainerConfigTab (and any // other client that needs to render a provider/instance selector). @@ -428,32 +416,24 @@ type computeMetadataResponse struct { func ComputeMetadata(c *gin.Context) { // Render in the canvas-UX order (distinct from the validation // order — see workspaceComputeMetadataRenderOrder doc), pulling - // the label + default + instance-types for each from the SSOT - // maps. Three lookups per provider; O(providers) total. - providers := make([]computeProviderMetadata, 0, len(workspaceComputeMetadataRenderOrder)) + // the label + default + display-default + instance-types for each + // from the SSOT maps. O(providers) total. + providers := make([]string, 0, len(workspaceComputeMetadataRenderOrder)) + instanceTypes := make(map[string][]string, len(workspaceComputeMetadataRenderOrder)) + defaults := make(map[string]string, len(workspaceComputeMetadataRenderOrder)) + displayDefaults := make(map[string]string, len(workspaceComputeMetadataRenderOrder)) for _, id := range workspaceComputeMetadataRenderOrder { - // Label is required (panicked in init() if missing). If a - // future provider is added without a label, this is a - // boot-time crash, not a silent empty label. - label := workspaceComputeProviderLabels[id] - // Default + instance-types are also required — same - // SSOT-consistency rationale. A provider without a - // default or with zero instance types would fail the - // validation step downstream, so we want the metadata - // endpoint to surface that as a panic at boot, not as - // a silent empty render. - defaultInstance := workspaceComputeDefaultInstanceByProvider[id] - displayDefault := workspaceComputeDisplayDefaultByProvider[id] - instances := workspaceComputeInstanceTypesOrdered[id] - providers = append(providers, computeProviderMetadata{ - ID: id, - Label: label, - DefaultInstance: defaultInstance, - DisplayDefault: displayDefault, - Instances: instances, - }) + providers = append(providers, id) + instanceTypes[id] = workspaceComputeInstanceTypesOrdered[id] + defaults[id] = workspaceComputeDefaultInstanceByProvider[id] + displayDefaults[id] = workspaceComputeDisplayDefaultByProvider[id] } - c.JSON(200, computeMetadataResponse{Providers: providers}) + c.JSON(200, workspaceComputeOptionsResponse{ + Providers: providers, + InstanceTypes: instanceTypes, + Defaults: defaults, + DisplayDefaults: displayDefaults, + }) } func workspaceComputeIsZero(compute models.WorkspaceCompute) bool { diff --git a/workspace-server/internal/handlers/workspace_compute_test.go b/workspace-server/internal/handlers/workspace_compute_test.go index 90c61a6f3..2c038de37 100644 --- a/workspace-server/internal/handlers/workspace_compute_test.go +++ b/workspace-server/internal/handlers/workspace_compute_test.go @@ -838,7 +838,7 @@ func TestComputeMetadata_ReturnsProviderAllowlist(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) } - var resp computeMetadataResponse + var resp workspaceComputeOptionsResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } @@ -846,29 +846,28 @@ func TestComputeMetadata_ReturnsProviderAllowlist(t *testing.T) { t.Fatalf("expected 3 providers, got %d", len(resp.Providers)) } want := []struct { - id, label, defaultInstance, displayDefault string - instanceCount int + id string + defaultInstance string + displayDefault string + instanceCount int }{ - {"aws", "AWS (default)", "t3.medium", "t3.xlarge", 7}, - {"gcp", "GCP", "e2-standard-2", "e2-standard-4", 5}, - {"hetzner", "Hetzner", "cpx31", "cpx41", 9}, + {"aws", "t3.medium", "t3.xlarge", 7}, + {"gcp", "e2-standard-2", "e2-standard-4", 5}, + {"hetzner", "cpx31", "cpx41", 9}, } for i, w := range want { p := resp.Providers[i] - if p.ID != w.id { - t.Errorf("providers[%d].id = %q, want %q", i, p.ID, w.id) + if p != w.id { + t.Errorf("providers[%d] = %q, want %q", i, p, w.id) } - if p.Label != w.label { - t.Errorf("providers[%d].label = %q, want %q", i, p.Label, w.label) + if got := resp.Defaults[p]; got != w.defaultInstance { + t.Errorf("defaults[%q] = %q, want %q", p, got, w.defaultInstance) } - if p.DefaultInstance != w.defaultInstance { - t.Errorf("providers[%d].default_instance = %q, want %q", i, p.DefaultInstance, w.defaultInstance) + if got := resp.DisplayDefaults[p]; got != w.displayDefault { + t.Errorf("display_defaults[%q] = %q, want %q", p, got, w.displayDefault) } - if p.DisplayDefault != w.displayDefault { - t.Errorf("providers[%d].display_default = %q, want %q", i, p.DisplayDefault, w.displayDefault) - } - if len(p.Instances) != w.instanceCount { - t.Errorf("providers[%d].instances len = %d, want %d", i, len(p.Instances), w.instanceCount) + if got := len(resp.InstanceTypes[p]); got != w.instanceCount { + t.Errorf("instanceTypes[%q] len = %d, want %d", p, got, w.instanceCount) } } } diff --git a/workspace-server/internal/router/compute_metadata_route_test.go b/workspace-server/internal/router/compute_metadata_route_test.go index c32753ad5..e4e808ef6 100644 --- a/workspace-server/internal/router/compute_metadata_route_test.go +++ b/workspace-server/internal/router/compute_metadata_route_test.go @@ -22,7 +22,8 @@ import ( // The contract being pinned: // 1. The route is registered and reachable. // 2. The route is PUBLIC — no AdminAuth, no WorkspaceAuth. -// 3. The wire shape matches the canvas's expectation (same JSON keys). +// 3. The wire shape matches the canvas's expectation (same JSON keys): +// { providers, instanceTypes, defaults, display_defaults }. // 4. The in-tree Go consumer (handlers.workspaceComputeInstanceAllowlist) // AGREE with the endpoint's value. @@ -54,12 +55,10 @@ func TestComputeMetadata_ReturnsExpectedShape(t *testing.T) { r.ServeHTTP(w, req) var got struct { - Providers []struct { - ID string `json:"id"` - Label string `json:"label"` - DefaultInstance string `json:"default_instance"` - Instances []string `json:"instances"` - } `json:"providers"` + Providers []string `json:"providers"` + InstanceTypes map[string][]string `json:"instanceTypes"` + Defaults map[string]string `json:"defaults"` + DisplayDefaults map[string]string `json:"display_defaults"` } if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("unmarshal response: %v (body=%s)", err, w.Body.String()) @@ -69,26 +68,28 @@ func TestComputeMetadata_ReturnsExpectedShape(t *testing.T) { t.Fatalf("expected 3 providers, got %d", len(got.Providers)) } want := []struct { - id, label, defaultInstance string - instanceCount int + id string + defaultInstance string + displayDefault string + instanceCount int }{ - {"aws", "AWS (default)", "t3.medium", 7}, - {"gcp", "GCP", "e2-standard-2", 5}, - {"hetzner", "Hetzner", "cpx31", 9}, + {"aws", "t3.medium", "t3.xlarge", 7}, + {"gcp", "e2-standard-2", "e2-standard-4", 5}, + {"hetzner", "cpx31", "cpx41", 9}, } for i, w := range want { p := got.Providers[i] - if p.ID != w.id { - t.Errorf("providers[%d].id = %q, want %q", i, p.ID, w.id) + if p != w.id { + t.Errorf("providers[%d] = %q, want %q", i, p, w.id) } - if p.Label != w.label { - t.Errorf("providers[%d].label = %q, want %q", i, p.Label, w.label) + if got := got.Defaults[p]; got != w.defaultInstance { + t.Errorf("defaults[%q] = %q, want %q", p, got, w.defaultInstance) } - if p.DefaultInstance != w.defaultInstance { - t.Errorf("providers[%d].default_instance = %q, want %q", i, p.DefaultInstance, w.defaultInstance) + if got := got.DisplayDefaults[p]; got != w.displayDefault { + t.Errorf("display_defaults[%q] = %q, want %q", p, got, w.displayDefault) } - if len(p.Instances) != w.instanceCount { - t.Errorf("providers[%d].instances len = %d, want %d", i, len(p.Instances), w.instanceCount) + if got := len(got.InstanceTypes[p]); got != w.instanceCount { + t.Errorf("instanceTypes[%q] len = %d, want %d", p, got, w.instanceCount) } } } @@ -107,18 +108,16 @@ func TestComputeMetadata_AgreesWithInTreeAllowlist(t *testing.T) { r.ServeHTTP(w, req) var got struct { - Providers []struct { - ID string `json:"id"` - Instances []string `json:"instances"` - } `json:"providers"` + Providers []string `json:"providers"` + InstanceTypes map[string][]string `json:"instanceTypes"` } if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("unmarshal response: %v (body=%s)", err, w.Body.String()) } for _, p := range got.Providers { - if len(p.Instances) == 0 { - t.Errorf("provider %q has empty instances", p.ID) + if len(got.InstanceTypes[p]) == 0 { + t.Errorf("provider %q has empty instances", p) } } } -- 2.52.0 From 6fa5f1f8184c1fbb21398dae3673c51263b19fc8 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Mon, 15 Jun 2026 00:45:22 +0000 Subject: [PATCH 3/6] test(CreateWorkspaceDialog): prove non-AWS display default comes from SSOT Adds a SaaS-mode test that switches the cloud provider to Hetzner and asserts the display instance dropdown defaults to the value from /compute/metadata display_defaults, not the in-bundle fallback. This addresses the REQUEST_CHANGES feedback on PR #2881 about proving the contract against a non-AWS provider. Co-Authored-By: Claude --- .../__tests__/CreateWorkspaceDialog.test.tsx | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx b/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx index 04b51b1c4..486faf436 100644 --- a/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx +++ b/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx @@ -378,6 +378,63 @@ describe("CreateWorkspaceDialog", () => { }); }); + it("consumes a non-AWS SSOT display default when the cloud provider changes", async () => { + // Make the canvas think it is running on a SaaS tenant so the cloud-provider + // selector is rendered. Hetzner's SSOT display default (cpx51) differs from + // the in-bundle fallback (cpx41), proving the dropdown reads display_defaults + // for the selected provider rather than always defaulting to AWS. + const originalLocation = window.location; + vi.stubGlobal("location", { ...originalLocation, hostname: "acme.moleculesai.app" }); + + mockGet.mockImplementation(async (url: string) => { + if (url === "/compute/metadata") { + return { + providers: ["aws", "hetzner"], + instanceTypes: { + aws: ["t3.medium", "t3.xlarge"], + hetzner: ["cpx31", "cpx41", "cpx51"], + }, + defaults: { aws: "t3.medium", hetzner: "cpx31" }, + display_defaults: { aws: "t3.xlarge", hetzner: "cpx51" }, + }; + } + if (url === "/templates") return SAMPLE_TEMPLATES as any; + return SAMPLE_WORKSPACES as any; + }); + + await openDialog(); + fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { + target: { value: "Hetzner Display Agent" }, + }); + + fireEvent.change(screen.getByLabelText("Cloud provider") as HTMLSelectElement, { + target: { value: "hetzner" }, + }); + fireEvent.click(screen.getByLabelText("Enable display")); + + const instanceSelect = screen.getByLabelText("Instance") as HTMLSelectElement; + await waitFor(() => expect(instanceSelect.value).toBe("cpx51")); + + 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; + expect(body.compute).toEqual({ + instance_type: "cpx51", + volume: { root_gb: 80 }, + display: { + mode: "desktop-control", + protocol: "novnc", + width: 1920, + height: 1080, + }, + provider: "hetzner", + }); + + vi.stubGlobal("location", originalLocation); + }); + it("sends BYOK API key secrets when API key auth mode is selected", async () => { await openDialog(); fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { -- 2.52.0 From 481dec3bb0a103884ac0101ca3b1adcb49e90914 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Mon, 15 Jun 2026 09:56:10 +0000 Subject: [PATCH 4/6] chore: re-run SOP checklist acks after body update -- 2.52.0 From e6ba1c0ffda17484c112b675d5a7258d993d3fa3 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Mon, 15 Jun 2026 10:20:36 +0000 Subject: [PATCH 5/6] test(e2e): poll A2A queue + advisory infra-skip for local-provision (#2897 #2917) The advisory real-LLM local-provision lane fails when the platform A2A proxy returns a 202-queued envelope (or gateway errors) instead of a synchronous result. Mirror the staging-saas #2922 pattern: - Detect queued responses and poll /workspaces/{id}/a2a/queue/{queue_id} for the durable result. - Infra-skip the advisory lane (LIFECYCLE_LLM=minimax) on: * initial gateway/transport errors (5xx/000/curl_rc!=0), * queued response with no queue_id, * terminal failed/dropped queue status, * queue poll timeout (30/30 queued/dispatched/in_progress/empty). - Keep the mandatory stub lane fail-closed: it does not infra-skip. Fixes #2897 local-provision follow-up. Related to #2917 A2A degradation. --- .../e2e/test_local_provision_lifecycle_e2e.sh | 106 +++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/tests/e2e/test_local_provision_lifecycle_e2e.sh b/tests/e2e/test_local_provision_lifecycle_e2e.sh index c0f87f7ec..81b3f0e4a 100755 --- a/tests/e2e/test_local_provision_lifecycle_e2e.sh +++ b/tests/e2e/test_local_provision_lifecycle_e2e.sh @@ -614,6 +614,21 @@ else fi echo "" +# ---------------------------------------------------------------------------- +# Advisory-lane infra-skip helper (core#2917 follow-on). The mandatory stub +# lane must keep asserting the real proxy round-trip; only the advisory +# real-LLM lane may go green-with-skip when the platform A2A layer is under +# a known transient degradation (queue never drains / gateway errors). +infra_skip_advisory() { + local reason="$1" + local detail="${2:-}" + if [ "$LIFECYCLE_LLM" = "minimax" ]; then + echo "[$(date +%H:%M:%S)] ⚠️ scan_status: infra-skip:${reason}${detail:+ $detail}" + echo "=== Results: advisory infra-skip (${reason}) ===" + exit 0 + fi +} + # ---------------------------------------------------------------------------- # Step 5 — proxy reach (ws-:8000 Docker-DNS rewrite, end to end). # ---------------------------------------------------------------------------- @@ -640,9 +655,98 @@ print(json.dumps({'method':'message/send','params':{'message':{'role':'user','pa # slower than the stub; give the real-LLM call a longer ceiling. A2A_CEIL="$A2A_TIMEOUT" [ "$LIFECYCLE_LLM" = "minimax" ] && A2A_CEIL="${A2A_MINIMAX_TIMEOUT:-120}" -A2A=$(curl -s --max-time "$A2A_CEIL" -X POST "$BASE/workspaces/$WSID/a2a" \ + +# Capture both body and HTTP code so we can detect gateway/queued responses. +A2A_TMP=$(mktemp) +set +e +A2A_CODE=$(curl -s -o "$A2A_TMP" -w '%{http_code}' --max-time "$A2A_CEIL" \ + -X POST "$BASE/workspaces/$WSID/a2a" \ -H "Content-Type: application/json" \ -d "$A2A_BODY") +A2A_RC=$? +set -e +A2A=$(cat "$A2A_TMP" 2>/dev/null || echo "") +rm -f "$A2A_TMP" + +# Gateway/transport failure on the initial POST is an A2A-layer infra issue, +# not a local-provision code regression. Only skip the advisory lane. +if [ "$A2A_RC" -ne 0 ] || [ "$A2A_CODE" = "000" ] || [[ "$A2A_CODE" == 5* ]]; then + infra_skip_advisory "a2a-gateway-error" "curl_rc=$A2A_RC http=$A2A_CODE" +fi + +# core#2917: the A2A proxy can return a 202-queued envelope instead of a +# synchronous result. Poll the durable queue result; if the queue never drains, +# infra-skip the advisory lane rather than falsely blaming local-provision code. +A2A_QUEUED=$(printf '%s' "$A2A" | python3 -c " +import sys,json +try: + d=json.load(sys.stdin) + print('true' if d.get('queued') is True or (d.get('status') or '').lower() == 'queued' else 'false') +except Exception: + print('false')" 2>/dev/null || echo "false") +if [ "$A2A_QUEUED" = "true" ]; then + A2A_QID=$(printf '%s' "$A2A" | python3 -c " +import sys,json +try: + print(json.load(sys.stdin).get('queue_id','')) +except Exception: + print('')" 2>/dev/null || echo "") + if [ -z "$A2A_QID" ]; then + infra_skip_advisory "a2a-queued-no-queue-id" "initial POST was queued but returned no queue_id" + fi + echo " A2A queued (queue_id=$A2A_QID); polling durable result..." + A2A_POLL_TMP=$(mktemp) + A2A_LAST_STATUS="" + A2A_POLL_COUNT=0 + for poll_attempt in $(seq 1 30); do + : >"$A2A_POLL_TMP" + set +e + A2A_POLL_CODE=$(curl -s -o "$A2A_POLL_TMP" -w '%{http_code}' --max-time 30 \ + -H "X-Workspace-ID: $WSID" \ + "$BASE/workspaces/$WSID/a2a/queue/$A2A_QID" 2>/dev/null) + set -e + A2A_POLL_RESP=$(cat "$A2A_POLL_TMP" 2>/dev/null || echo "") + A2A_POLL_STATUS=$(printf '%s' "$A2A_POLL_RESP" | python3 -c " +import sys,json +try: + print(json.load(sys.stdin).get('status','')) +except Exception: + print('')" 2>/dev/null || echo "") + A2A_LAST_STATUS="$A2A_POLL_STATUS" + A2A_POLL_COUNT=$poll_attempt + case "$A2A_POLL_STATUS" in + completed) + A2A=$(printf '%s' "$A2A_POLL_RESP" | python3 -c " +import sys,json +try: + rb=json.load(sys.stdin).get('response_body') + print(json.dumps(rb) if rb is not None else '') +except Exception: + print('')" 2>/dev/null || echo "") + if [ -n "$A2A" ]; then + break + fi + ;; + failed|dropped) + rm -f "$A2A_POLL_TMP" + infra_skip_advisory "a2a-queue-terminal" "queue_id=$A2A_QID status=$A2A_POLL_STATUS" + ;; + queued|dispatched|in_progress|"") + echo " queue poll $poll_attempt/30 status=$A2A_POLL_STATUS — backing off 2s" + sleep 2 + ;; + *) + rm -f "$A2A_POLL_TMP" + infra_skip_advisory "a2a-queue-unexpected" "queue_id=$A2A_QID status=$A2A_POLL_STATUS" + ;; + esac + done + rm -f "$A2A_POLL_TMP" + if [ -z "$A2A" ]; then + infra_skip_advisory "a2a-queue-timeout" "queue_id=$A2A_QID poll_count=${A2A_POLL_COUNT}/30 last_status=${A2A_LAST_STATUS:-}" + fi +fi + # Extract the assistant text part once (shared by the minimax assertion + # diagnostics). Tolerates result.parts[].text and result.message.parts[].text. a2a_text() { -- 2.52.0 From 05ee0f6f022153bc48a929164ce0713345bf1ec7 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Mon, 15 Jun 2026 10:24:07 +0000 Subject: [PATCH 6/6] =?UTF-8?q?chore:=20shellcheck=20clean=20=E2=80=94=20d?= =?UTF-8?q?rop=20unused=20A2A=5FPOLL=5FCODE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/e2e/test_local_provision_lifecycle_e2e.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/test_local_provision_lifecycle_e2e.sh b/tests/e2e/test_local_provision_lifecycle_e2e.sh index 81b3f0e4a..89e4d1404 100755 --- a/tests/e2e/test_local_provision_lifecycle_e2e.sh +++ b/tests/e2e/test_local_provision_lifecycle_e2e.sh @@ -701,9 +701,9 @@ except Exception: for poll_attempt in $(seq 1 30); do : >"$A2A_POLL_TMP" set +e - A2A_POLL_CODE=$(curl -s -o "$A2A_POLL_TMP" -w '%{http_code}' --max-time 30 \ + curl -s -o "$A2A_POLL_TMP" -w '%{http_code}' --max-time 30 \ -H "X-Workspace-ID: $WSID" \ - "$BASE/workspaces/$WSID/a2a/queue/$A2A_QID" 2>/dev/null) + "$BASE/workspaces/$WSID/a2a/queue/$A2A_QID" >/dev/null 2>&1 set -e A2A_POLL_RESP=$(cat "$A2A_POLL_TMP" 2>/dev/null || echo "") A2A_POLL_STATUS=$(printf '%s' "$A2A_POLL_RESP" | python3 -c " -- 2.52.0