feat(display): add desktop workspace creation flow
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 11s
CI / Detect changes (pull_request) Successful in 13s
CI / Python Lint & Test (pull_request) Successful in 6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Chat / detect-changes (pull_request) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 15s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 12s
Harness Replays / detect-changes (pull_request) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 38s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 59s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Successful in 3s
security-review / approved (pull_request) Successful in 3s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 5s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m48s
E2E Chat / E2E Chat (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
Harness Replays / Harness Replays (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m6s
CI / Platform (Go) (pull_request) Successful in 5m11s
CI / Canvas (Next.js) (pull_request) Successful in 6m56s
CI / all-required (pull_request) Successful in 11m14s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 11s
CI / Detect changes (pull_request) Successful in 13s
CI / Python Lint & Test (pull_request) Successful in 6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Chat / detect-changes (pull_request) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 15s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 12s
Harness Replays / detect-changes (pull_request) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 38s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 59s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Successful in 3s
security-review / approved (pull_request) Successful in 3s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 5s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m48s
E2E Chat / E2E Chat (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
Harness Replays / Harness Replays (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m6s
CI / Platform (Go) (pull_request) Successful in 5m11s
CI / Canvas (Next.js) (pull_request) Successful in 6m56s
CI / all-required (pull_request) Successful in 11m14s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
This commit is contained in:
@@ -33,6 +33,8 @@ interface HermesProvider {
|
||||
models: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_CREATE_MODEL = "anthropic:claude-opus-4-7";
|
||||
|
||||
// All providers supported by Hermes runtime via providers.resolve_provider().
|
||||
// `defaultModel` is the slug injected into the workspace provision request
|
||||
// when the user picks this provider — template-hermes's derive-provider.sh
|
||||
@@ -68,6 +70,10 @@ export function CreateWorkspaceButton() {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
|
||||
const [displayEnabled, setDisplayEnabled] = useState(false);
|
||||
const [displayInstanceType, setDisplayInstanceType] = useState("t3.xlarge");
|
||||
const [displayRootGB, setDisplayRootGB] = useState("80");
|
||||
const [displayResolution, setDisplayResolution] = useState("1920x1080");
|
||||
// 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
|
||||
@@ -223,6 +229,10 @@ export function CreateWorkspaceButton() {
|
||||
setParentId("");
|
||||
setBudgetLimit("");
|
||||
setError(null);
|
||||
setDisplayEnabled(false);
|
||||
setDisplayInstanceType("t3.xlarge");
|
||||
setDisplayRootGB("80");
|
||||
setDisplayResolution("1920x1080");
|
||||
setHermesProvider("anthropic");
|
||||
setExternalRuntime("external");
|
||||
setHermesApiKey("");
|
||||
@@ -264,6 +274,8 @@ export function CreateWorkspaceButton() {
|
||||
const parsedBudget = budgetLimit.trim()
|
||||
? parseFloat(budgetLimit)
|
||||
: null;
|
||||
const [displayWidth, displayHeight] = displayResolution.split("x").map((v) => parseInt(v, 10));
|
||||
const parsedRootGB = parseInt(displayRootGB, 10);
|
||||
|
||||
const createResp = await api.post<{
|
||||
id: string;
|
||||
@@ -280,6 +292,21 @@ export function CreateWorkspaceButton() {
|
||||
tier,
|
||||
parent_id: parentId || undefined,
|
||||
budget_limit: parsedBudget,
|
||||
...(!isExternal && !isHermes ? { model: DEFAULT_CREATE_MODEL } : {}),
|
||||
...(displayEnabled
|
||||
? {
|
||||
compute: {
|
||||
instance_type: displayInstanceType,
|
||||
volume: { root_gb: Number.isFinite(parsedRootGB) ? parsedRootGB : 80 },
|
||||
display: {
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: Number.isFinite(displayWidth) ? displayWidth : 1920,
|
||||
height: Number.isFinite(displayHeight) ? displayHeight : 1080,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
|
||||
// Runtime=external flips the backend into awaiting-agent mode:
|
||||
// no container provisioning, token minted, connection payload
|
||||
@@ -447,6 +474,73 @@ export function CreateWorkspaceButton() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isExternal && (
|
||||
<div className="rounded-lg border border-line/50 bg-surface-card/40 p-3">
|
||||
<div className="mb-2 text-[11px] font-medium text-ink-mid">
|
||||
Container Config
|
||||
</div>
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-medium text-ink">Display</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={displayEnabled}
|
||||
onChange={(e) => setDisplayEnabled(e.target.checked)}
|
||||
aria-label="Enable display"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</label>
|
||||
{displayEnabled && (
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label htmlFor="display-instance-type" className="mb-1 block text-[11px] text-ink-mid">
|
||||
Instance
|
||||
</label>
|
||||
<select
|
||||
id="display-instance-type"
|
||||
value={displayInstanceType}
|
||||
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"
|
||||
>
|
||||
<option value="t3.large">t3.large</option>
|
||||
<option value="t3.xlarge">t3.xlarge</option>
|
||||
<option value="m6i.xlarge">m6i.xlarge</option>
|
||||
<option value="c6i.xlarge">c6i.xlarge</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="display-root-gb" className="mb-1 block text-[11px] text-ink-mid">
|
||||
Disk GB
|
||||
</label>
|
||||
<input
|
||||
id="display-root-gb"
|
||||
type="number"
|
||||
min="30"
|
||||
max="500"
|
||||
value={displayRootGB}
|
||||
onChange={(e) => setDisplayRootGB(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"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label htmlFor="display-resolution" className="mb-1 block text-[11px] text-ink-mid">
|
||||
Resolution
|
||||
</label>
|
||||
<select
|
||||
id="display-resolution"
|
||||
value={displayResolution}
|
||||
onChange={(e) => setDisplayResolution(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"
|
||||
>
|
||||
<option value="1920x1080">1920 x 1080</option>
|
||||
<option value="1600x900">1600 x 900</option>
|
||||
<option value="1280x720">1280 x 720</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] text-ink-mid block mb-1">
|
||||
Parent Workspace
|
||||
|
||||
@@ -123,6 +123,46 @@ describe("CreateWorkspaceDialog", () => {
|
||||
expect(body.parent_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits compute config by default", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Plain Agent" },
|
||||
});
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.compute).toBeUndefined();
|
||||
expect(body.model).toBe("anthropic:claude-opus-4-7");
|
||||
});
|
||||
|
||||
it("sends display compute profile when desktop display is enabled", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Desktop Agent" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Enable display"));
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.model).toBe("anthropic:claude-opus-4-7");
|
||||
expect(body.compute).toEqual({
|
||||
instance_type: "t3.xlarge",
|
||||
volume: { root_gb: 80 },
|
||||
display: {
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders gracefully when GET /workspaces fails", async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error("Network error"));
|
||||
await openDialog();
|
||||
|
||||
@@ -11,6 +11,7 @@ interface DisplayStatus {
|
||||
protocol?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
viewer_url?: string;
|
||||
}
|
||||
|
||||
interface DisplayControlStatus {
|
||||
@@ -93,6 +94,31 @@ export function DisplayTab({ workspaceId }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const releaseControl = async () => {
|
||||
const generation = requestGeneration.current;
|
||||
const controlPath = `/workspaces/${workspaceId}/display/control`;
|
||||
setControlBusy(true);
|
||||
setControlError(null);
|
||||
try {
|
||||
const next = await api.post<DisplayControlStatus>(`${controlPath}/release`, {});
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(next);
|
||||
} catch (err) {
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControlError("Failed to release control");
|
||||
try {
|
||||
const latest = await api.get<DisplayControlStatus>(controlPath);
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(latest);
|
||||
} catch {
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(null);
|
||||
}
|
||||
} finally {
|
||||
if (requestGeneration.current === generation) setControlBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-5">
|
||||
@@ -185,7 +211,97 @@ export function DisplayTab({ workspaceId }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
<div className="flex h-full min-h-[360px] flex-col bg-surface-sunken/30">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-line/50 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-ink">Desktop</h3>
|
||||
<p className="mt-0.5 font-mono text-[10px] text-ink-mid">
|
||||
{status.mode || "desktop-control"} · {status.protocol || "display"}
|
||||
</p>
|
||||
</div>
|
||||
<DisplayControlBar
|
||||
control={control}
|
||||
controlBusy={controlBusy}
|
||||
controlError={controlError}
|
||||
onAcquire={acquireControl}
|
||||
onRelease={releaseControl}
|
||||
/>
|
||||
</div>
|
||||
{status.viewer_url ? (
|
||||
<iframe
|
||||
title="Workspace desktop"
|
||||
src={status.viewer_url}
|
||||
className="min-h-0 flex-1 border-0 bg-black"
|
||||
allow="clipboard-read; clipboard-write; fullscreen; pointer-lock"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center p-8 text-center">
|
||||
<div>
|
||||
<h3 className="mb-1.5 text-sm font-medium text-ink">Display session is not ready.</h3>
|
||||
<p className="max-w-xs text-[11px] leading-relaxed text-ink-mid">
|
||||
This workspace has display configuration, but the desktop session URL is not available yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayControlBar({
|
||||
control,
|
||||
controlBusy,
|
||||
controlError,
|
||||
onAcquire,
|
||||
onRelease,
|
||||
}: {
|
||||
control: DisplayControlStatus | null;
|
||||
controlBusy: boolean;
|
||||
controlError: string | null;
|
||||
onAcquire: () => void;
|
||||
onRelease: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{control && (
|
||||
<div className="min-w-0 text-right">
|
||||
<p className="truncate text-[11px] font-medium text-ink">
|
||||
{control.controller === "none"
|
||||
? "No active controller"
|
||||
: `Controlled by ${displayControlActorLabel(control)}`}
|
||||
</p>
|
||||
{control.expires_at && (
|
||||
<p className="mt-0.5 truncate font-mono text-[10px] text-ink-mid">
|
||||
Until {new Date(control.expires_at).toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
{controlError && <p className="mt-0.5 text-[10px] text-red-200">{controlError}</p>}
|
||||
</div>
|
||||
)}
|
||||
{control?.controller === "none" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAcquire}
|
||||
disabled={controlBusy}
|
||||
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Take control
|
||||
</button>
|
||||
)}
|
||||
{control?.controller === "user" && control.controlled_by === "admin-token" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRelease}
|
||||
disabled={controlBusy}
|
||||
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Release
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function displayControlActorLabel(control: DisplayControlStatus): string {
|
||||
|
||||
@@ -71,6 +71,61 @@ describe("DisplayTab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the desktop stream when a display session is available", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: true,
|
||||
mode: "desktop-control",
|
||||
protocol: "dcv",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
viewer_url: "https://display.example.test/session/ws-display",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Workspace desktop")).toBeTruthy();
|
||||
});
|
||||
const frame = screen.getByTitle("Workspace desktop") as HTMLIFrameElement;
|
||||
expect(frame.src).toBe("https://display.example.test/session/ws-display");
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("releases user display control", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: true,
|
||||
mode: "desktop-control",
|
||||
protocol: "dcv",
|
||||
viewer_url: "https://display.example.test/session/ws-display",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "user",
|
||||
controlled_by: "admin-token",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
});
|
||||
mockPost.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Release" })).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Release" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/release", {});
|
||||
});
|
||||
|
||||
it("renders active display control locks as observe-only", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
@@ -15,6 +18,10 @@ import (
|
||||
const (
|
||||
workspaceComputeDiskFloorGB = 30
|
||||
workspaceComputeDiskCeilingGB = 500
|
||||
workspaceDisplayMinWidth = 800
|
||||
workspaceDisplayMaxWidth = 3840
|
||||
workspaceDisplayMinHeight = 600
|
||||
workspaceDisplayMaxHeight = 2160
|
||||
)
|
||||
|
||||
type workspaceDisplayResponse struct {
|
||||
@@ -25,6 +32,7 @@ type workspaceDisplayResponse struct {
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ViewerURL string `json:"viewer_url,omitempty"`
|
||||
}
|
||||
|
||||
var workspaceComputeInstanceAllowlist = map[string]struct{}{
|
||||
@@ -54,12 +62,12 @@ func validateWorkspaceCompute(compute models.WorkspaceCompute) error {
|
||||
return fmt.Errorf("unsupported compute.display.mode")
|
||||
}
|
||||
switch compute.Display.Protocol {
|
||||
case "", "dcv":
|
||||
case "", "dcv", "novnc":
|
||||
default:
|
||||
return fmt.Errorf("unsupported compute.display.protocol")
|
||||
}
|
||||
if compute.Display.Width < 0 || compute.Display.Height < 0 {
|
||||
return fmt.Errorf("compute.display width/height must be non-negative")
|
||||
if err := validateWorkspaceDisplayDimensions(compute.Display.Width, compute.Display.Height); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -71,13 +79,26 @@ func validateWorkspaceDisplayConfig(display models.WorkspaceComputeDisplay) erro
|
||||
return fmt.Errorf("unsupported compute.display.mode")
|
||||
}
|
||||
switch display.Protocol {
|
||||
case "", "dcv":
|
||||
case "", "dcv", "novnc":
|
||||
default:
|
||||
return fmt.Errorf("unsupported compute.display.protocol")
|
||||
}
|
||||
if display.Width < 0 || display.Height < 0 {
|
||||
if err := validateWorkspaceDisplayDimensions(display.Width, display.Height); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWorkspaceDisplayDimensions(width, height int) error {
|
||||
if width < 0 || height < 0 {
|
||||
return fmt.Errorf("compute.display width/height must be non-negative")
|
||||
}
|
||||
if width != 0 && (width < workspaceDisplayMinWidth || width > workspaceDisplayMaxWidth) {
|
||||
return fmt.Errorf("compute.display.width must be between %d and %d", workspaceDisplayMinWidth, workspaceDisplayMaxWidth)
|
||||
}
|
||||
if height != 0 && (height < workspaceDisplayMinHeight || height > workspaceDisplayMaxHeight) {
|
||||
return fmt.Errorf("compute.display.height must be between %d and %d", workspaceDisplayMinHeight, workspaceDisplayMaxHeight)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -196,6 +217,18 @@ func (h *WorkspaceHandler) Display(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if viewerURL := workspaceDisplayViewerURL(workspaceID); viewerURL != "" {
|
||||
c.JSON(200, workspaceDisplayResponse{
|
||||
Available: true,
|
||||
Mode: compute.Display.Mode,
|
||||
Protocol: compute.Display.Protocol,
|
||||
Width: compute.Display.Width,
|
||||
Height: compute.Display.Height,
|
||||
Status: "ready",
|
||||
ViewerURL: viewerURL,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(200, workspaceDisplayResponse{
|
||||
Available: false,
|
||||
Reason: "display_session_unavailable",
|
||||
@@ -206,3 +239,15 @@ func (h *WorkspaceHandler) Display(c *gin.Context) {
|
||||
Status: "not_configured",
|
||||
})
|
||||
}
|
||||
|
||||
func workspaceDisplayViewerURL(workspaceID string) string {
|
||||
base := strings.TrimRight(os.Getenv("DISPLAY_VIEWER_BASE_URL"), "/")
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(base)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return base + "/" + url.PathEscape(workspaceID)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,20 @@ func TestValidateWorkspaceCompute_RejectsOutOfRangeRootVolume(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceCompute_RejectsOutOfRangeDisplayDimensions(t *testing.T) {
|
||||
for _, display := range []models.WorkspaceComputeDisplay{
|
||||
{Mode: "desktop-control", Protocol: "novnc", Width: 799, Height: 1080},
|
||||
{Mode: "desktop-control", Protocol: "novnc", Width: 3841, Height: 1080},
|
||||
{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 599},
|
||||
{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 2161},
|
||||
} {
|
||||
compute := models.WorkspaceCompute{Display: display}
|
||||
if err := validateWorkspaceCompute(compute); err == nil {
|
||||
t.Fatalf("validateWorkspaceCompute accepted display size %dx%d", display.Width, display.Height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceComputeJSON_OmitsEmptyNestedSections(t *testing.T) {
|
||||
got, err := workspaceComputeJSON(models.WorkspaceCompute{
|
||||
InstanceType: "m6i.xlarge",
|
||||
@@ -110,6 +124,7 @@ func TestWorkspaceCreate_WithInvalidCompute_ReturnsBadRequest(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{
|
||||
"name":"Oversized Agent",
|
||||
"model":"anthropic:claude-opus-4-7",
|
||||
"compute":{"instance_type":"p4d.24xlarge"}
|
||||
}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
@@ -140,6 +155,7 @@ func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) {
|
||||
Compute: models.WorkspaceCompute{
|
||||
InstanceType: "m6i.xlarge",
|
||||
Volume: models.WorkspaceComputeVolume{RootGB: 100},
|
||||
Display: models.WorkspaceComputeDisplay{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 1080},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
@@ -153,6 +169,12 @@ func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) {
|
||||
if cfg.DiskGB != 100 {
|
||||
t.Errorf("cfg.DiskGB = %d, want 100", cfg.DiskGB)
|
||||
}
|
||||
if cfg.Display.Mode != "desktop-control" || cfg.Display.Protocol != "novnc" {
|
||||
t.Errorf("cfg.Display mode/protocol = %q/%q, want desktop-control/novnc", cfg.Display.Mode, cfg.Display.Protocol)
|
||||
}
|
||||
if cfg.Display.Width != 1920 || cfg.Display.Height != 1080 {
|
||||
t.Errorf("cfg.Display size = %dx%d, want 1920x1080", cfg.Display.Width, cfg.Display.Height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithStoredCompute_LoadsComputeForRestartPayloads(t *testing.T) {
|
||||
@@ -216,7 +238,7 @@ func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -241,8 +263,8 @@ func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *
|
||||
if resp["status"] != "not_configured" {
|
||||
t.Fatalf("status = %v, want not_configured", resp["status"])
|
||||
}
|
||||
if resp["mode"] != "desktop-control" || resp["protocol"] != "dcv" {
|
||||
t.Fatalf("mode/protocol = %v/%v, want desktop-control/dcv", resp["mode"], resp["protocol"])
|
||||
if resp["mode"] != "desktop-control" || resp["protocol"] != "novnc" {
|
||||
t.Fatalf("mode/protocol = %v/%v, want desktop-control/novnc", resp["mode"], resp["protocol"])
|
||||
}
|
||||
if resp["width"] != float64(1920) || resp["height"] != float64(1080) {
|
||||
t.Fatalf("width/height = %v/%v, want 1920/1080", resp["width"], resp["height"])
|
||||
@@ -255,6 +277,83 @@ func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay_DisplayConfiguredWithViewerBaseReturnsAvailableSession(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
t.Setenv("DISPLAY_VIEWER_BASE_URL", "https://display.example.test/sessions")
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display/display", nil)
|
||||
|
||||
handler.Display(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse display response: %v", err)
|
||||
}
|
||||
if resp["available"] != true {
|
||||
t.Fatalf("available = %v, want true", resp["available"])
|
||||
}
|
||||
if resp["viewer_url"] != "https://display.example.test/sessions/ws-display" {
|
||||
t.Fatalf("viewer_url = %v, want workspace viewer URL", resp["viewer_url"])
|
||||
}
|
||||
if resp["reason"] != nil {
|
||||
t.Fatalf("reason = %v, want omitted", resp["reason"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay_DisplayConfiguredWithInvalidViewerBaseReturnsUnavailable(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
t.Setenv("DISPLAY_VIEWER_BASE_URL", "http://display.example.test/sessions")
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
workspaceID := "ws-display"
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(workspaceID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: workspaceID}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/"+workspaceID+"/display", nil)
|
||||
|
||||
handler.Display(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse display response: %v", err)
|
||||
}
|
||||
if resp["available"] != false {
|
||||
t.Fatalf("available = %v, want false", resp["available"])
|
||||
}
|
||||
if resp["viewer_url"] != nil {
|
||||
t.Fatalf("viewer_url = %v, want omitted for invalid viewer base", resp["viewer_url"])
|
||||
}
|
||||
if resp["reason"] != "display_session_unavailable" {
|
||||
t.Fatalf("reason = %v, want display_session_unavailable", resp["reason"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay_IgnoresUnrelatedStoredComputeSizingDrift(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
@@ -262,7 +361,7 @@ func TestWorkspaceDisplay_IgnoresUnrelatedStoredComputeSizingDrift(t *testing.T)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display-sizing-drift").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"old.large","display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"old.large","display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -288,16 +288,22 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
|
||||
}
|
||||
|
||||
return provisioner.WorkspaceConfig{
|
||||
WorkspaceID: workspaceID,
|
||||
TemplatePath: templatePath,
|
||||
ConfigFiles: configFiles,
|
||||
PluginsPath: pluginsPath,
|
||||
WorkspacePath: workspacePath,
|
||||
WorkspaceAccess: workspaceAccess,
|
||||
Tier: payload.Tier,
|
||||
Runtime: payload.Runtime,
|
||||
InstanceType: payload.Compute.InstanceType,
|
||||
DiskGB: int32(payload.Compute.Volume.RootGB),
|
||||
WorkspaceID: workspaceID,
|
||||
TemplatePath: templatePath,
|
||||
ConfigFiles: configFiles,
|
||||
PluginsPath: pluginsPath,
|
||||
WorkspacePath: workspacePath,
|
||||
WorkspaceAccess: workspaceAccess,
|
||||
Tier: payload.Tier,
|
||||
Runtime: payload.Runtime,
|
||||
InstanceType: payload.Compute.InstanceType,
|
||||
DiskGB: int32(payload.Compute.Volume.RootGB),
|
||||
Display: provisioner.WorkspaceDisplayConfig{
|
||||
Mode: payload.Compute.Display.Mode,
|
||||
Width: payload.Compute.Display.Width,
|
||||
Height: payload.Compute.Display.Height,
|
||||
Protocol: payload.Compute.Display.Protocol,
|
||||
},
|
||||
EnvVars: envVars,
|
||||
PlatformURL: h.platformURL,
|
||||
AwarenessURL: os.Getenv("AWARENESS_URL"),
|
||||
|
||||
@@ -152,14 +152,15 @@ func (p *CPProvisioner) adminAuthHeaders(req *http.Request) {
|
||||
}
|
||||
|
||||
type cpProvisionRequest struct {
|
||||
OrgID string `json:"org_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Runtime string `json:"runtime"`
|
||||
Tier int `json:"tier"`
|
||||
InstanceType string `json:"instance_type,omitempty"`
|
||||
DiskGB int32 `json:"disk_gb,omitempty"`
|
||||
PlatformURL string `json:"platform_url"`
|
||||
Env map[string]string `json:"env"`
|
||||
OrgID string `json:"org_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Runtime string `json:"runtime"`
|
||||
Tier int `json:"tier"`
|
||||
InstanceType string `json:"instance_type,omitempty"`
|
||||
DiskGB int32 `json:"disk_gb,omitempty"`
|
||||
Display WorkspaceDisplayConfig `json:"display,omitempty"`
|
||||
PlatformURL string `json:"platform_url"`
|
||||
Env map[string]string `json:"env"`
|
||||
// ConfigFiles are template + generated config files to write into the
|
||||
// EC2 instance's /configs directory. OFFSEC-010: collected by
|
||||
// collectCPConfigFiles which rejects symlinks and non-regular files
|
||||
@@ -214,6 +215,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
Tier: cfg.Tier,
|
||||
InstanceType: cfg.InstanceType,
|
||||
DiskGB: cfg.DiskGB,
|
||||
Display: cfg.Display,
|
||||
PlatformURL: cfg.PlatformURL,
|
||||
Env: env,
|
||||
ConfigFiles: configFiles,
|
||||
|
||||
@@ -197,6 +197,12 @@ func TestStart_HappyPath(t *testing.T) {
|
||||
if body.DiskGB != 100 {
|
||||
t.Errorf("disk_gb = %d, want 100", body.DiskGB)
|
||||
}
|
||||
if body.Display.Mode != "desktop-control" || body.Display.Protocol != "novnc" {
|
||||
t.Errorf("display mode/protocol = %q/%q, want desktop-control/novnc", body.Display.Mode, body.Display.Protocol)
|
||||
}
|
||||
if body.Display.Width != 1920 || body.Display.Height != 1080 {
|
||||
t.Errorf("display size = %dx%d, want 1920x1080", body.Display.Width, body.Display.Height)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, `{"instance_id":"i-abc123","state":"pending"}`)
|
||||
}))
|
||||
@@ -212,6 +218,7 @@ func TestStart_HappyPath(t *testing.T) {
|
||||
id, err := p.Start(context.Background(), WorkspaceConfig{
|
||||
WorkspaceID: "ws-1", Runtime: "python", Tier: 1, PlatformURL: "http://tenant",
|
||||
InstanceType: "m6i.xlarge", DiskGB: 100,
|
||||
Display: WorkspaceDisplayConfig{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 1080},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
|
||||
@@ -97,9 +97,10 @@ type WorkspaceConfig struct {
|
||||
PluginsPath string // Host path to plugins directory (mounted at /plugins)
|
||||
WorkspacePath string // Host path to bind-mount as /workspace (if empty, uses Docker named volume)
|
||||
Tier int
|
||||
Runtime string // "langgraph" (default) or "claude-code", "codex", "ollama", "custom"
|
||||
InstanceType string // Optional CP EC2 instance type override (SaaS only)
|
||||
DiskGB int32 // Optional CP root volume size override in GiB (SaaS only)
|
||||
Runtime string // "langgraph" (default) or "claude-code", "codex", "ollama", "custom"
|
||||
InstanceType string // Optional CP EC2 instance type override (SaaS only)
|
||||
DiskGB int32 // Optional CP root volume size override in GiB (SaaS only)
|
||||
Display WorkspaceDisplayConfig
|
||||
EnvVars map[string]string // Additional env vars (API keys, etc.)
|
||||
PlatformURL string
|
||||
AwarenessURL string
|
||||
@@ -122,6 +123,13 @@ type WorkspaceConfig struct {
|
||||
Image string
|
||||
}
|
||||
|
||||
type WorkspaceDisplayConfig struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
}
|
||||
|
||||
// selectImage resolves the final Docker image ref for a workspace. The handler
|
||||
// layer is the source of truth — if it set cfg.Image (the digest-pinned form
|
||||
// supplied by CP, the SSOT for runtime image pins; molecule-core's own
|
||||
|
||||
Reference in New Issue
Block a user