diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 7d6dbdb9f..00c96e5e8 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -3,6 +3,14 @@ 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, +} from "@/lib/compute-options"; import { isSaaSTenant } from "@/lib/tenant"; import { ExternalConnectModal, type ExternalConnectionInfo } from "./ExternalConnectModal"; import { @@ -49,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" }, @@ -57,19 +76,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 +91,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 +710,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..486faf436 100644 --- a/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx +++ b/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx @@ -16,6 +16,25 @@ import { api } from "@/lib/api"; const mockGet = vi.mocked(api.get); const mockPost = vi.mocked(api.post); +const SAMPLE_COMPUTE_METADATA = { + 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 = [ { id: "ws-1", name: "Platform Team", tier: 1 }, { id: "ws-2", name: "Research Agent", tier: 2 }, @@ -103,6 +122,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 +321,120 @@ 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.instanceTypes.aws); + }); + }); + + 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: ["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; + 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("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"), { diff --git a/canvas/src/components/tabs/ContainerConfigTab.tsx b/canvas/src/components/tabs/ContainerConfigTab.tsx index 481ad0d8d..8d868a3ca 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. @@ -274,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 new file mode 100644 index 000000000..1a5d77f52 --- /dev/null +++ b/canvas/src/lib/__tests__/compute-options.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { + parseComputeOptions, + defaultInstanceForProvider, + displayDefaultForProvider, + instanceTypesForProvider, + normalizeProvider, +} from "../compute-options"; + +describe("compute-options", () => { + const serverResponse = { + 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", () => { + 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?.instanceTypes.aws).toEqual(["t3.medium", "t3.large", "t3.xlarge"]); + }); + + it("falls back to in-bundle defaults when display_defaults is missing", () => { + const partial = { + providers: ["aws"], + instanceTypes: { aws: ["t3.medium"] }, + defaults: { aws: "t3.medium" }, + }; + const opts = parseComputeOptions(partial); + expect(opts).toBeNull(); + }); + + it("returns null for malformed payloads", () => { + expect(parseComputeOptions(null)).toBeNull(); + expect(parseComputeOptions({})).toBeNull(); + expect(parseComputeOptions({ providers: [] })).toBeNull(); + expect(parseComputeOptions({ providers: [""], instanceTypes: {}, defaults: {}, display_defaults: {} })).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(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..f88144f83 --- /dev/null +++ b/canvas/src/lib/compute-options.ts @@ -0,0 +1,107 @@ +// Cloud-provider + instance-type metadata (core#2489). +// +// 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 +// 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 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; +}; + +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" }, +}; + +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; + +// 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, + 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[] = []; + for (const p of providers) { + 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: pickStringArrays(instanceTypes), + defaults: pickStrings(defaults), + displayDefaults: pickStrings(display_defaults), + }; +} diff --git a/tests/e2e/test_local_provision_lifecycle_e2e.sh b/tests/e2e/test_local_provision_lifecycle_e2e.sh index c0f87f7ec..89e4d1404 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 + curl -s -o "$A2A_POLL_TMP" -w '%{http_code}' --max-time 30 \ + -H "X-Workspace-ID: $WSID" \ + "$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 " +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() { diff --git a/workspace-server/internal/handlers/workspace_compute.go b/workspace-server/internal/handlers/workspace_compute.go index 72dd3abed..6037a23aa 100644 --- a/workspace-server/internal/handlers/workspace_compute.go +++ b/workspace-server/internal/handlers/workspace_compute.go @@ -399,17 +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"` - 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). @@ -427,30 +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] - instances := workspaceComputeInstanceTypesOrdered[id] - providers = append(providers, computeProviderMetadata{ - ID: id, - Label: label, - DefaultInstance: defaultInstance, - 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 4b7c13646..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,26 +846,28 @@ 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 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 := 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 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) } } }