diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 432883ef6ac..95381916fbe 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -85,6 +85,7 @@ "fast-glob": "^3.3.3", "file-uri-to-path": "^1.0.0", "framer-motion": "^12.23.26", + "freestyle-sandboxes": "^0.1.3", "fuse.js": "^7.1.0", "http-proxy": "^1.18.1", "idb": "^8.0.3", diff --git a/apps/desktop/src/lib/trpc/routers/cloud-terminal/index.ts b/apps/desktop/src/lib/trpc/routers/cloud-terminal/index.ts new file mode 100644 index 00000000000..28d0042fc5c --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/cloud-terminal/index.ts @@ -0,0 +1,182 @@ +import { cloudWorkspaces } from "@superset/local-db"; +import { observable } from "@trpc/server/observable"; +import { eq } from "drizzle-orm"; +import { cloudTerminalManager } from "main/lib/cloud-terminal"; +import { localDb } from "main/lib/local-db"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +/** + * Cloud Terminal router for managing remote terminal sessions on cloud workspaces + * + * Uses the Freestyle SDK to connect to cloud VMs and establish WebSocket-based + * terminal sessions. Sessions are keyed by paneId and linked to cloud workspaces. + */ +export const createCloudTerminalRouter = () => { + return router({ + /** + * Create or attach to a cloud terminal session + */ + createOrAttach: publicProcedure + .input( + z.object({ + paneId: z.string(), + tabId: z.string(), + cloudWorkspaceId: z.string(), + cols: z.number().optional(), + rows: z.number().optional(), + }), + ) + .mutation(async ({ input }) => { + const { paneId, tabId, cloudWorkspaceId, cols, rows } = input; + + // Get cloud workspace to find the VM ID + const cloudWorkspace = localDb + .select() + .from(cloudWorkspaces) + .where(eq(cloudWorkspaces.id, cloudWorkspaceId)) + .get(); + + if (!cloudWorkspace) { + throw new Error(`Cloud workspace ${cloudWorkspaceId} not found`); + } + + if (!cloudWorkspace.provider_vm_id) { + throw new Error( + `Cloud workspace ${cloudWorkspaceId} does not have a VM assigned`, + ); + } + + if (cloudWorkspace.status !== "running") { + throw new Error( + `Cloud workspace ${cloudWorkspaceId} is not running (status: ${cloudWorkspace.status})`, + ); + } + + const result = await cloudTerminalManager.createOrAttach({ + paneId, + tabId, + cloudWorkspaceId, + vmId: cloudWorkspace.provider_vm_id, + cols, + rows, + }); + + return { + paneId, + isNew: result.isNew, + scrollback: result.scrollback, + wasRecovered: result.wasRecovered, + viewportY: result.viewportY, + }; + }), + + /** + * Write data to cloud terminal + */ + write: publicProcedure + .input( + z.object({ + paneId: z.string(), + data: z.string(), + }), + ) + .mutation(({ input }) => { + cloudTerminalManager.write(input); + }), + + /** + * Resize cloud terminal + */ + resize: publicProcedure + .input( + z.object({ + paneId: z.string(), + cols: z.number(), + rows: z.number(), + }), + ) + .mutation(({ input }) => { + cloudTerminalManager.resize(input); + }), + + /** + * Kill cloud terminal session + */ + kill: publicProcedure + .input( + z.object({ + paneId: z.string(), + }), + ) + .mutation(async ({ input }) => { + await cloudTerminalManager.kill(input); + }), + + /** + * Detach from cloud terminal (keep session alive) + */ + detach: publicProcedure + .input( + z.object({ + paneId: z.string(), + viewportY: z.number().optional(), + }), + ) + .mutation(({ input }) => { + cloudTerminalManager.detach(input); + }), + + /** + * Clear scrollback buffer for cloud terminal + */ + clearScrollback: publicProcedure + .input( + z.object({ + paneId: z.string(), + }), + ) + .mutation(({ input }) => { + cloudTerminalManager.clearScrollback(input); + }), + + /** + * Get cloud terminal session info + */ + getSession: publicProcedure + .input(z.string()) + .query(({ input: paneId }) => { + return cloudTerminalManager.getSession(paneId); + }), + + /** + * Stream data from cloud terminal + */ + stream: publicProcedure + .input(z.string()) + .subscription(({ input: paneId }) => { + return observable< + | { type: "data"; data: string } + | { type: "exit"; exitCode: number; signal?: number } + >((emit) => { + const onData = (data: string) => { + emit.next({ type: "data", data }); + }; + + const onExit = (exitCode: number, signal?: number) => { + emit.next({ type: "exit", exitCode, signal }); + emit.complete(); + }; + + cloudTerminalManager.on(`data:${paneId}`, onData); + cloudTerminalManager.on(`exit:${paneId}`, onExit); + + // Cleanup on unsubscribe + return () => { + cloudTerminalManager.off(`data:${paneId}`, onData); + cloudTerminalManager.off(`exit:${paneId}`, onExit); + }; + }); + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 66ad3766fea..136e15a2814 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -4,6 +4,7 @@ import { createAnalyticsRouter } from "./analytics"; import { createAuthRouter } from "./auth"; import { createAutoUpdateRouter } from "./auto-update"; import { createChangesRouter } from "./changes"; +import { createCloudTerminalRouter } from "./cloud-terminal"; import { createConfigRouter } from "./config"; import { createExternalRouter } from "./external"; import { createHotkeysRouter } from "./hotkeys"; @@ -30,6 +31,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { projects: createProjectsRouter(getWindow), workspaces: createWorkspacesRouter(), terminal: createTerminalRouter(), + cloudTerminal: createCloudTerminalRouter(), changes: createChangesRouter(), notifications: createNotificationsRouter(), ports: createPortsRouter(), diff --git a/apps/desktop/src/main/lib/cloud-terminal/index.ts b/apps/desktop/src/main/lib/cloud-terminal/index.ts new file mode 100644 index 00000000000..4598f667041 --- /dev/null +++ b/apps/desktop/src/main/lib/cloud-terminal/index.ts @@ -0,0 +1,2 @@ +export { cloudTerminalManager, CloudTerminalManager } from "./manager"; +export * from "./types"; diff --git a/apps/desktop/src/main/lib/cloud-terminal/manager.ts b/apps/desktop/src/main/lib/cloud-terminal/manager.ts new file mode 100644 index 00000000000..24ee26610e6 --- /dev/null +++ b/apps/desktop/src/main/lib/cloud-terminal/manager.ts @@ -0,0 +1,389 @@ +import { EventEmitter } from "node:events"; +import { SerializeAddon } from "@xterm/addon-serialize"; +import { Terminal as HeadlessTerminal } from "@xterm/headless"; +import { freestyle } from "freestyle-sandboxes"; +import { DataBatcher } from "../data-batcher"; +import type { + CloudSessionResult, + CloudTerminalSession, + CreateCloudSessionParams, +} from "./types"; + +const DEFAULT_COLS = 80; +const DEFAULT_ROWS = 24; + +// Polling interval for terminal output (ms) +const POLL_INTERVAL_MS = 1000; + +/** + * Creates a headless xterm terminal for capturing scrollback + */ +function createHeadlessTerminal(params: { cols: number; rows: number }) { + const headless = new HeadlessTerminal({ + cols: params.cols, + rows: params.rows, + scrollback: 10000, + allowProposedApi: true, + }); + const serializer = new SerializeAddon(); + headless.loadAddon(serializer); + return { headless, serializer }; +} + +/** + * Get serialized scrollback from headless terminal + */ +function getSerializedScrollback(session: CloudTerminalSession): string { + try { + return session.serializer.serialize(); + } catch { + return ""; + } +} + +/** + * Cloud Terminal Manager for managing remote terminal sessions via Freestyle + * + * NOTE: The current Freestyle SDK (v0.1.3) only provides read-only access to + * terminal output via getOutput(). Full interactive terminal support with + * WebSocket connections is planned for a future version. + * + * Current implementation: + * - Polls terminal output from Freestyle API + * - Displays terminal history/logs in read-only mode + * + * Future implementation will: + * - Establish WebSocket connections for real-time I/O + * - Support bidirectional terminal input/output + */ +export class CloudTerminalManager extends EventEmitter { + private sessions = new Map(); + private pendingSessions = new Map>(); + private pollIntervals = new Map>(); + + async createOrAttach( + params: CreateCloudSessionParams, + ): Promise { + const { paneId, cols, rows } = params; + + // Deduplicate concurrent calls + const pending = this.pendingSessions.get(paneId); + if (pending) { + return pending; + } + + // Return existing session if alive + const existing = this.sessions.get(paneId); + if (existing?.isAlive) { + existing.lastActive = Date.now(); + if (cols !== undefined && rows !== undefined) { + this.resize({ paneId, cols, rows }); + } + return { + isNew: false, + scrollback: getSerializedScrollback(existing), + wasRecovered: existing.wasRecovered, + viewportY: existing.viewportY, + }; + } + + // Create new session + const creationPromise = this.doCreateSession(params); + this.pendingSessions.set(paneId, creationPromise); + + try { + return await creationPromise; + } finally { + this.pendingSessions.delete(paneId); + } + } + + private async doCreateSession( + params: CreateCloudSessionParams, + ): Promise { + const { + paneId, + cloudWorkspaceId, + vmId, + cols = DEFAULT_COLS, + rows = DEFAULT_ROWS, + } = params; + + // Create headless terminal for scrollback + const { headless, serializer } = createHeadlessTerminal({ cols, rows }); + + // Create data batcher for efficient data emission + const dataBatcher = new DataBatcher((data) => { + this.emit(`data:${paneId}`, data); + }); + + // Get VM reference and list existing terminals + const vm = freestyle.vms.ref({ vmId }); + const terminalInfo = await vm.terminals.list(); + + // Use the first available terminal or throw if none exists + if (!terminalInfo.terminals || terminalInfo.terminals.length === 0) { + throw new Error( + `No terminals available for VM ${vmId}. The VM may not have started yet.`, + ); + } + + const terminalName = terminalInfo.terminals[0].name; + + // Create session object + const session: CloudTerminalSession = { + paneId, + cloudWorkspaceId, + vmId, + terminalId: terminalName, + cols, + rows, + lastActive: Date.now(), + headless, + serializer, + isAlive: true, + wasRecovered: false, + dataBatcher, + startTime: Date.now(), + }; + + this.sessions.set(paneId, session); + + // Fetch initial terminal output + await this.fetchTerminalOutput(session); + + // Start polling for terminal output updates + // NOTE: This is a temporary workaround until WebSocket support is added + const pollInterval = setInterval(() => { + if (session.isAlive) { + void this.fetchTerminalOutput(session); + } + }, POLL_INTERVAL_MS); + this.pollIntervals.set(paneId, pollInterval); + + console.log( + `[CloudTerminalManager] Created cloud terminal session for pane ${paneId} on VM ${vmId} (terminal: ${terminalName})`, + ); + + return { + isNew: true, + scrollback: getSerializedScrollback(session), + wasRecovered: session.wasRecovered, + }; + } + + /** + * Fetch terminal output from Freestyle API + * NOTE: This is a read-only operation - input is not supported yet + */ + private async fetchTerminalOutput(session: CloudTerminalSession): Promise { + try { + const vm = freestyle.vms.ref({ vmId: session.vmId }); + const result = await vm.terminals.getOutput({ + terminalId: session.terminalId, + }); + + if (result?.output) { + session.headless.write(result.output); + session.dataBatcher.write(result.output); + } + } catch (error) { + console.error( + `[CloudTerminalManager] Failed to fetch terminal output:`, + error, + ); + } + } + + write(params: { paneId: string; data: string }): void { + const { paneId } = params; + const session = this.sessions.get(paneId); + + if (!session || !session.isAlive) { + throw new Error( + `Cloud terminal session ${paneId} not found or not alive`, + ); + } + + // NOTE: Terminal input is not yet supported via the Freestyle SDK + // This would require WebSocket-based terminal access which isn't + // currently available in the SDK + console.warn( + `[CloudTerminalManager] Terminal input not yet supported for cloud terminals`, + ); + session.lastActive = Date.now(); + } + + resize(params: { paneId: string; cols: number; rows: number }): void { + const { paneId, cols, rows } = params; + + if ( + !Number.isInteger(cols) || + !Number.isInteger(rows) || + cols <= 0 || + rows <= 0 + ) { + console.warn( + `[CloudTerminalManager] Invalid resize geometry for ${paneId}: cols=${cols}, rows=${rows}`, + ); + return; + } + + const session = this.sessions.get(paneId); + + if (!session || !session.isAlive) { + console.warn( + `Cannot resize cloud terminal ${paneId}: session not found or not alive`, + ); + return; + } + + try { + session.headless.resize(cols, rows); + session.cols = cols; + session.rows = rows; + session.lastActive = Date.now(); + // NOTE: Remote terminal resize not yet supported via SDK + } catch (error) { + console.error( + `[CloudTerminalManager] Failed to resize terminal ${paneId}:`, + error, + ); + } + } + + async kill(params: { paneId: string }): Promise { + const { paneId } = params; + const session = this.sessions.get(paneId); + + if (!session) { + console.warn( + `Cannot kill cloud terminal ${paneId}: session not found`, + ); + return; + } + + // Stop polling + const interval = this.pollIntervals.get(paneId); + if (interval) { + clearInterval(interval); + this.pollIntervals.delete(paneId); + } + + session.isAlive = false; + session.dataBatcher.flush(); + session.headless.dispose(); + this.sessions.delete(paneId); + + this.emit(`exit:${paneId}`, 0); + } + + detach(params: { paneId: string; viewportY?: number }): void { + const { paneId, viewportY } = params; + const session = this.sessions.get(paneId); + + if (!session) { + console.warn( + `Cannot detach cloud terminal ${paneId}: session not found`, + ); + return; + } + + session.lastActive = Date.now(); + if (viewportY !== undefined) { + session.viewportY = viewportY; + } + } + + clearScrollback(params: { paneId: string }): void { + const { paneId } = params; + const session = this.sessions.get(paneId); + + if (!session) { + console.warn( + `Cannot clear scrollback for cloud terminal ${paneId}: session not found`, + ); + return; + } + + // Recreate headless terminal + session.headless.dispose(); + const { headless, serializer } = createHeadlessTerminal({ + cols: session.cols, + rows: session.rows, + }); + session.headless = headless; + session.serializer = serializer; + session.lastActive = Date.now(); + } + + getSession( + paneId: string, + ): { isAlive: boolean; lastActive: number } | null { + const session = this.sessions.get(paneId); + if (!session) { + return null; + } + + return { + isAlive: session.isAlive, + lastActive: session.lastActive, + }; + } + + async killByCloudWorkspaceId( + cloudWorkspaceId: string, + ): Promise<{ killed: number; failed: number }> { + const sessionsToKill = Array.from(this.sessions.entries()).filter( + ([, session]) => session.cloudWorkspaceId === cloudWorkspaceId, + ); + + if (sessionsToKill.length === 0) { + return { killed: 0, failed: 0 }; + } + + let killed = 0; + let failed = 0; + + for (const [paneId] of sessionsToKill) { + try { + await this.kill({ paneId }); + killed++; + } catch { + failed++; + } + } + + return { killed, failed }; + } + + detachAllListeners(): void { + for (const event of this.eventNames()) { + const name = String(event); + if (name.startsWith("data:") || name.startsWith("exit:")) { + this.removeAllListeners(event); + } + } + } + + async cleanup(): Promise { + // Stop all polling intervals + for (const interval of this.pollIntervals.values()) { + clearInterval(interval); + } + this.pollIntervals.clear(); + + // Kill all sessions + const killPromises: Promise[] = []; + for (const [paneId] of this.sessions.entries()) { + killPromises.push(this.kill({ paneId })); + } + + await Promise.all(killPromises); + this.sessions.clear(); + this.removeAllListeners(); + } +} + +/** Singleton cloud terminal manager instance */ +export const cloudTerminalManager = new CloudTerminalManager(); diff --git a/apps/desktop/src/main/lib/cloud-terminal/types.ts b/apps/desktop/src/main/lib/cloud-terminal/types.ts new file mode 100644 index 00000000000..f0d514c703f --- /dev/null +++ b/apps/desktop/src/main/lib/cloud-terminal/types.ts @@ -0,0 +1,60 @@ +import type { SerializeAddon } from "@xterm/addon-serialize"; +import type { Terminal as HeadlessTerminal } from "@xterm/headless"; +import type { DataBatcher } from "../data-batcher"; + +export interface CloudTerminalSession { + paneId: string; + cloudWorkspaceId: string; + vmId: string; + terminalId: string; + cols: number; + rows: number; + lastActive: number; + headless: HeadlessTerminal; + serializer: SerializeAddon; + isAlive: boolean; + wasRecovered: boolean; + dataBatcher: DataBatcher; + startTime: number; + /** Saved viewport scroll position for restoration on reattach */ + viewportY?: number; + /** WebSocket connection for Freestyle terminal */ + ws?: WebSocket; +} + +export interface CloudTerminalDataEvent { + type: "data"; + data: string; +} + +export interface CloudTerminalExitEvent { + type: "exit"; + exitCode: number; + signal?: number; +} + +export type CloudTerminalEvent = CloudTerminalDataEvent | CloudTerminalExitEvent; + +export interface CloudSessionResult { + isNew: boolean; + scrollback: string; + wasRecovered: boolean; + viewportY?: number; +} + +export interface CreateCloudSessionParams { + paneId: string; + tabId: string; + cloudWorkspaceId: string; + vmId: string; + cols?: number; + rows?: number; +} + +export interface CloudSSHCredentials { + host: string; + port: number; + username: string; + privateKey?: string; + token?: string; +} diff --git a/apps/desktop/src/renderer/components/CloudWorkspace/CloudWorkspaceListItem/CloudWorkspaceListItem.tsx b/apps/desktop/src/renderer/components/CloudWorkspace/CloudWorkspaceListItem/CloudWorkspaceListItem.tsx new file mode 100644 index 00000000000..d1a67fc9c6b --- /dev/null +++ b/apps/desktop/src/renderer/components/CloudWorkspace/CloudWorkspaceListItem/CloudWorkspaceListItem.tsx @@ -0,0 +1,168 @@ +import type { SelectCloudWorkspace } from "@superset/db/schema"; +import { Button } from "@superset/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; +import { cn } from "@superset/ui/utils"; +import { formatDistanceToNow } from "date-fns"; +import { + HiOutlineCloud, + HiOutlinePause, + HiOutlinePlay, + HiOutlineStop, + HiOutlineTrash, +} from "react-icons/hi2"; +import { GoGitBranch } from "react-icons/go"; +import { + useDeleteCloudWorkspace, + usePauseCloudWorkspace, + useResumeCloudWorkspace, + useStopCloudWorkspace, +} from "renderer/react-query/cloud-workspaces"; +import { CloudWorkspaceStatusBadge } from "../CloudWorkspaceStatusBadge"; + +interface CloudWorkspaceListItemProps { + workspace: SelectCloudWorkspace; + isSelected?: boolean; + onSelect?: () => void; + onConnect?: () => void; +} + +export function CloudWorkspaceListItem({ + workspace, + isSelected, + onSelect, + onConnect, +}: CloudWorkspaceListItemProps) { + const pauseWorkspace = usePauseCloudWorkspace(); + const resumeWorkspace = useResumeCloudWorkspace(); + const stopWorkspace = useStopCloudWorkspace(); + const deleteWorkspace = useDeleteCloudWorkspace(); + + const canPause = workspace.status === "running"; + const canResume = workspace.status === "paused"; + const canStop = + workspace.status === "running" || workspace.status === "paused"; + const canConnect = workspace.status === "running"; + const canDelete = + workspace.status === "stopped" || workspace.status === "error"; + + const handlePause = () => { + pauseWorkspace.mutate({ workspaceId: workspace.id }); + }; + + const handleResume = () => { + resumeWorkspace.mutate({ workspaceId: workspace.id }); + }; + + const handleStop = () => { + stopWorkspace.mutate({ workspaceId: workspace.id }); + }; + + const handleDelete = () => { + deleteWorkspace.mutate({ workspaceId: workspace.id }); + }; + + const lastActiveText = workspace.lastActiveAt + ? `Active ${formatDistanceToNow(new Date(workspace.lastActiveAt), { addSuffix: true })}` + : null; + + return ( + + + + )} + + + + + {canConnect && ( + + + Connect + + )} + {canPause && ( + + + Pause + + )} + {canResume && ( + + + Resume + + )} + {canStop && ( + + + Stop + + )} + {(canConnect || canPause || canResume || canStop) && canDelete && ( + + )} + {canDelete && ( + + + Delete + + )} + + + ); +} diff --git a/apps/desktop/src/renderer/components/CloudWorkspace/CloudWorkspaceListItem/index.ts b/apps/desktop/src/renderer/components/CloudWorkspace/CloudWorkspaceListItem/index.ts new file mode 100644 index 00000000000..63590423798 --- /dev/null +++ b/apps/desktop/src/renderer/components/CloudWorkspace/CloudWorkspaceListItem/index.ts @@ -0,0 +1 @@ +export { CloudWorkspaceListItem } from "./CloudWorkspaceListItem"; diff --git a/apps/desktop/src/renderer/components/CloudWorkspace/CloudWorkspaceStatusBadge/CloudWorkspaceStatusBadge.tsx b/apps/desktop/src/renderer/components/CloudWorkspace/CloudWorkspaceStatusBadge/CloudWorkspaceStatusBadge.tsx new file mode 100644 index 00000000000..07b9427ed40 --- /dev/null +++ b/apps/desktop/src/renderer/components/CloudWorkspace/CloudWorkspaceStatusBadge/CloudWorkspaceStatusBadge.tsx @@ -0,0 +1,91 @@ +import type { CloudWorkspaceStatus } from "@superset/db/schema"; +import { cn } from "@superset/ui/utils"; +import { + HiOutlineCloud, + HiOutlinePause, + HiOutlinePlay, + HiOutlineXCircle, +} from "react-icons/hi2"; +import { ImSpinner8 } from "react-icons/im"; + +const STATUS_CONFIG = { + provisioning: { + icon: ImSpinner8, + label: "Provisioning", + bgColor: "bg-blue-500/20", + textColor: "text-blue-400", + animate: true, + }, + running: { + icon: HiOutlinePlay, + label: "Running", + bgColor: "bg-green-500/20", + textColor: "text-green-400", + animate: false, + }, + paused: { + icon: HiOutlinePause, + label: "Paused", + bgColor: "bg-amber-500/20", + textColor: "text-amber-400", + animate: false, + }, + stopped: { + icon: HiOutlineCloud, + label: "Stopped", + bgColor: "bg-muted", + textColor: "text-muted-foreground", + animate: false, + }, + error: { + icon: HiOutlineXCircle, + label: "Error", + bgColor: "bg-red-500/20", + textColor: "text-red-400", + animate: false, + }, +} as const satisfies Record< + CloudWorkspaceStatus, + { + icon: React.ComponentType<{ className?: string }>; + label: string; + bgColor: string; + textColor: string; + animate: boolean; + } +>; + +interface CloudWorkspaceStatusBadgeProps { + status: CloudWorkspaceStatus; + showLabel?: boolean; + size?: "sm" | "md"; +} + +export function CloudWorkspaceStatusBadge({ + status, + showLabel = true, + size = "md", +}: CloudWorkspaceStatusBadgeProps) { + const config = STATUS_CONFIG[status]; + const Icon = config.icon; + + return ( +
+ + {showLabel && {config.label}} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/CloudWorkspace/CloudWorkspaceStatusBadge/index.ts b/apps/desktop/src/renderer/components/CloudWorkspace/CloudWorkspaceStatusBadge/index.ts new file mode 100644 index 00000000000..bbd67975667 --- /dev/null +++ b/apps/desktop/src/renderer/components/CloudWorkspace/CloudWorkspaceStatusBadge/index.ts @@ -0,0 +1 @@ +export { CloudWorkspaceStatusBadge } from "./CloudWorkspaceStatusBadge"; diff --git a/apps/desktop/src/renderer/components/CloudWorkspace/index.ts b/apps/desktop/src/renderer/components/CloudWorkspace/index.ts new file mode 100644 index 00000000000..88304bade8c --- /dev/null +++ b/apps/desktop/src/renderer/components/CloudWorkspace/index.ts @@ -0,0 +1,3 @@ +export { CloudWorkspaceListItem } from "./CloudWorkspaceListItem"; +export { CloudWorkspaceStatusBadge } from "./CloudWorkspaceStatusBadge"; +export { CloudWorkspaceModal } from "../CloudWorkspaceModal"; diff --git a/apps/desktop/src/renderer/components/CloudWorkspaceModal/CloudWorkspaceModal.tsx b/apps/desktop/src/renderer/components/CloudWorkspaceModal/CloudWorkspaceModal.tsx new file mode 100644 index 00000000000..9fac64c56e4 --- /dev/null +++ b/apps/desktop/src/renderer/components/CloudWorkspaceModal/CloudWorkspaceModal.tsx @@ -0,0 +1,369 @@ +import type { SelectRepository } from "@superset/db/schema"; +import { Button } from "@superset/ui/button"; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useEffect, useMemo, useState } from "react"; +import { GoGitBranch } from "react-icons/go"; +import { + HiCheck, + HiChevronUpDown, + HiOutlineCloud, + HiOutlineServer, +} from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; +import { useCreateCloudWorkspace } from "renderer/react-query/cloud-workspaces"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { + useCloseCloudWorkspaceModal, + useCloudWorkspaceModalOpen, + useCloudWorkspaceModalStore, +} from "renderer/stores/cloud-workspace-modal"; + +export function CloudWorkspaceModal() { + const isOpen = useCloudWorkspaceModalOpen(); + const closeModal = useCloseCloudWorkspaceModal(); + const preSelectedRepositoryId = useCloudWorkspaceModalStore( + (state) => state.preSelectedRepositoryId, + ); + + const [selectedRepoId, setSelectedRepoId] = useState(null); + const [name, setName] = useState(""); + const [branch, setBranch] = useState(""); + const [autoStopMinutes, setAutoStopMinutes] = useState("30"); + const [repoSearchOpen, setRepoSearchOpen] = useState(false); + const [repoSearch, setRepoSearch] = useState(""); + const [branchSearchOpen, setBranchSearchOpen] = useState(false); + const [branchSearch, setBranchSearch] = useState(""); + + // Get repositories from Electric SQL + const collections = useCollections(); + const { data: repositoriesData } = useLiveQuery((q) => + q + .from({ repositories: collections.repositories }) + .select(({ repositories }) => repositories), + ); + const repositories = repositoriesData ?? []; + + // Get branches for selected repo via desktop trpc (projects router) + const { data: branchesData, isLoading: isBranchesLoading } = + trpc.projects.getBranches.useQuery( + { projectId: selectedRepoId ?? "" }, + { enabled: !!selectedRepoId }, + ); + + const createCloudWorkspace = useCreateCloudWorkspace({ + onSuccess: () => { + handleClose(); + }, + }); + + // Find selected repository + const selectedRepo = useMemo(() => { + return repositories.find((r: SelectRepository) => r.id === selectedRepoId); + }, [repositories, selectedRepoId]); + + // Filter repositories by search + const filteredRepos = useMemo(() => { + if (!repoSearch) return repositories; + const searchLower = repoSearch.toLowerCase(); + return repositories.filter( + (r: SelectRepository) => + r.name.toLowerCase().includes(searchLower) || + r.repoOwner.toLowerCase().includes(searchLower), + ); + }, [repositories, repoSearch]); + + // Filter branches by search + const filteredBranches = useMemo(() => { + if (!branchesData?.branches) return []; + if (!branchSearch) return branchesData.branches; + const searchLower = branchSearch.toLowerCase(); + return branchesData.branches.filter((b: { name: string }) => + b.name.toLowerCase().includes(searchLower), + ); + }, [branchesData?.branches, branchSearch]); + + // Auto-select repository when modal opens + useEffect(() => { + if (isOpen && !selectedRepoId && preSelectedRepositoryId) { + setSelectedRepoId(preSelectedRepositoryId); + } + }, [isOpen, selectedRepoId, preSelectedRepositoryId]); + + // Set default branch when repo changes + useEffect(() => { + if (branchesData?.defaultBranch && !branch) { + setBranch(branchesData.defaultBranch); + } + }, [branchesData?.defaultBranch, branch]); + + // Generate default name from repo and branch + useEffect(() => { + if (selectedRepo && branch && !name) { + const defaultName = `${selectedRepo.name}-${branch}`; + setName(defaultName); + } + }, [selectedRepo, branch, name]); + + const resetForm = () => { + setSelectedRepoId(null); + setName(""); + setBranch(""); + setAutoStopMinutes("30"); + setRepoSearch(""); + setBranchSearch(""); + }; + + const handleClose = () => { + closeModal(); + resetForm(); + }; + + const handleCreate = async () => { + if (!selectedRepoId || !name.trim() || !branch) return; + + const repo = repositories.find( + (r: SelectRepository) => r.id === selectedRepoId, + ); + if (!repo) return; + + await createCloudWorkspace.mutateAsync({ + organizationId: repo.organizationId, + repositoryId: selectedRepoId, + name: name.trim(), + branch, + providerType: "freestyle", + autoStopMinutes: Number.parseInt(autoStopMinutes) || 30, + }); + }; + + const canCreate = + selectedRepoId && name.trim() && branch && !createCloudWorkspace.isPending; + + return ( + !open && handleClose()}> + + + + + New Cloud Workspace + + + Create a cloud workspace to develop on a remote VM. + + + +
+ {/* Repository Selection */} +
+ + + + + + + + + + No repositories found + {filteredRepos.map((repo: SelectRepository) => ( + { + setSelectedRepoId(repo.id); + setBranch(""); // Reset branch when repo changes + setName(""); // Reset name when repo changes + setRepoSearchOpen(false); + setRepoSearch(""); + }} + className="flex items-center justify-between" + > + + + + {repo.repoOwner}/{repo.name} + + + {selectedRepoId === repo.id && ( + + )} + + ))} + + + + +
+ + {/* Branch Selection */} +
+ + + + + + + + + + No branches found + {filteredBranches.map((b: { name: string }) => ( + { + setBranch(b.name); + setBranchSearchOpen(false); + setBranchSearch(""); + }} + className="flex items-center justify-between" + > + + + {b.name} + {b.name === branchesData?.defaultBranch && ( + + default + + )} + + {branch === b.name && ( + + )} + + ))} + + + + +
+ + {/* Workspace Name */} +
+ + setName(e.target.value)} + placeholder="my-cloud-workspace" + /> +
+ + {/* Auto-Stop Timer */} +
+ + +

+ Workspace will pause automatically to save resources +

+
+ + {/* Create Button */} + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/CloudWorkspaceModal/index.ts b/apps/desktop/src/renderer/components/CloudWorkspaceModal/index.ts new file mode 100644 index 00000000000..dca250b689d --- /dev/null +++ b/apps/desktop/src/renderer/components/CloudWorkspaceModal/index.ts @@ -0,0 +1 @@ +export { CloudWorkspaceModal } from "./CloudWorkspaceModal"; diff --git a/apps/desktop/src/renderer/react-query/cloud-workspaces/index.ts b/apps/desktop/src/renderer/react-query/cloud-workspaces/index.ts new file mode 100644 index 00000000000..38649b1f511 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/cloud-workspaces/index.ts @@ -0,0 +1,16 @@ +export { + useCloudWorkspace, + useCloudWorkspaces, + useCloudWorkspacesByStatus, + type CloudWorkspace, +} from "./useCloudWorkspaces"; + +export { + useCreateCloudWorkspace, + useDeleteCloudWorkspace, + useJoinCloudWorkspace, + useLeaveCloudWorkspace, + usePauseCloudWorkspace, + useResumeCloudWorkspace, + useStopCloudWorkspace, +} from "./useCloudWorkspaceMutations"; diff --git a/apps/desktop/src/renderer/react-query/cloud-workspaces/useCloudWorkspaceMutations.ts b/apps/desktop/src/renderer/react-query/cloud-workspaces/useCloudWorkspaceMutations.ts new file mode 100644 index 00000000000..72ed404f580 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/cloud-workspaces/useCloudWorkspaceMutations.ts @@ -0,0 +1,191 @@ +import type { SelectCloudWorkspace } from "@superset/db/schema"; +import { toast } from "@superset/ui/sonner"; +import { useMutation } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { + useApiClient, + useCollections, +} from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +interface CreateCloudWorkspaceInput { + organizationId: string; + repositoryId: string; + name: string; + branch: string; + providerType?: "freestyle" | "fly"; + autoStopMinutes?: number; +} + +/** + * Create a new cloud workspace using the collection insert method + */ +export function useCreateCloudWorkspace(options?: { + onSuccess?: () => void; + onError?: (error: Error) => void; +}) { + const collections = useCollections(); + + const mutate = useCallback( + async (input: CreateCloudWorkspaceInput) => { + const newWorkspace: SelectCloudWorkspace = { + id: crypto.randomUUID(), + organizationId: input.organizationId, + repositoryId: input.repositoryId, + name: input.name, + branch: input.branch, + providerType: input.providerType ?? "freestyle", + providerVmId: null, + status: "provisioning", + statusMessage: null, + creatorId: "", // Will be set by backend + autoStopMinutes: input.autoStopMinutes ?? 30, + lastActiveAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + try { + collections.cloudWorkspaces.insert(newWorkspace); + toast.success("Cloud workspace created", { + description: "Provisioning VM...", + }); + options?.onSuccess?.(); + } catch (error) { + const err = error instanceof Error ? error : new Error("Unknown error"); + toast.error("Failed to create cloud workspace", { + description: err.message, + }); + options?.onError?.(err); + throw err; + } + }, + [collections, options], + ); + + return { + mutate, + mutateAsync: mutate, + isPending: false, + }; +} + +/** + * Pause a running cloud workspace + */ +export function usePauseCloudWorkspace() { + const apiClient = useApiClient(); + + return useMutation({ + mutationFn: async ({ workspaceId }: { workspaceId: string }) => { + return apiClient.cloudWorkspace.pause.mutate({ workspaceId }); + }, + onSuccess: () => { + toast.success("Cloud workspace paused"); + }, + onError: (error: Error) => { + toast.error("Failed to pause workspace", { + description: error.message, + }); + }, + }); +} + +/** + * Resume a paused cloud workspace + */ +export function useResumeCloudWorkspace() { + const apiClient = useApiClient(); + + return useMutation({ + mutationFn: async ({ workspaceId }: { workspaceId: string }) => { + return apiClient.cloudWorkspace.resume.mutate({ workspaceId }); + }, + onSuccess: () => { + toast.success("Cloud workspace resumed"); + }, + onError: (error: Error) => { + toast.error("Failed to resume workspace", { + description: error.message, + }); + }, + }); +} + +/** + * Stop a cloud workspace + */ +export function useStopCloudWorkspace() { + const apiClient = useApiClient(); + + return useMutation({ + mutationFn: async ({ workspaceId }: { workspaceId: string }) => { + return apiClient.cloudWorkspace.stop.mutate({ workspaceId }); + }, + onSuccess: () => { + toast.success("Cloud workspace stopped"); + }, + onError: (error: Error) => { + toast.error("Failed to stop workspace", { + description: error.message, + }); + }, + }); +} + +/** + * Delete a cloud workspace + */ +export function useDeleteCloudWorkspace() { + const collections = useCollections(); + + return useMutation({ + mutationFn: async ({ workspaceId }: { workspaceId: string }) => { + collections.cloudWorkspaces.delete(workspaceId); + }, + onSuccess: () => { + toast.success("Cloud workspace deleted"); + }, + onError: (error: Error) => { + toast.error("Failed to delete workspace", { + description: error.message, + }); + }, + }); +} + +/** + * Join a cloud workspace session + */ +export function useJoinCloudWorkspace() { + const apiClient = useApiClient(); + + return useMutation({ + mutationFn: async ({ + workspaceId, + clientType, + }: { + workspaceId: string; + clientType: "desktop" | "web"; + }) => { + return apiClient.cloudWorkspace.join.mutate({ workspaceId, clientType }); + }, + onError: (error: Error) => { + toast.error("Failed to join workspace session", { + description: error.message, + }); + }, + }); +} + +/** + * Leave a cloud workspace session + */ +export function useLeaveCloudWorkspace() { + const apiClient = useApiClient(); + + return useMutation({ + mutationFn: async ({ sessionId }: { sessionId: string }) => { + return apiClient.cloudWorkspace.leave.mutate({ sessionId }); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/cloud-workspaces/useCloudWorkspaces.ts b/apps/desktop/src/renderer/react-query/cloud-workspaces/useCloudWorkspaces.ts new file mode 100644 index 00000000000..4c3217e4d47 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/cloud-workspaces/useCloudWorkspaces.ts @@ -0,0 +1,48 @@ +import type { SelectCloudWorkspace } from "@superset/db/schema"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +/** + * Query cloud workspaces for the current organization from Electric SQL collection + */ +export function useCloudWorkspaces(): SelectCloudWorkspace[] { + const collections = useCollections(); + const { data } = useLiveQuery((q) => + q + .from({ cloudWorkspaces: collections.cloudWorkspaces }) + .select(({ cloudWorkspaces }) => cloudWorkspaces), + ); + return data ?? []; +} + +/** + * Get a single cloud workspace by ID + */ +export function useCloudWorkspace( + workspaceId: string | null | undefined, +): SelectCloudWorkspace | null { + const cloudWorkspaces = useCloudWorkspaces(); + + if (!workspaceId) return null; + + return cloudWorkspaces.find((ws) => ws.id === workspaceId) ?? null; +} + +/** + * Get cloud workspaces grouped by status + */ +export function useCloudWorkspacesByStatus() { + const cloudWorkspaces = useCloudWorkspaces(); + + const running = cloudWorkspaces.filter((ws) => ws.status === "running"); + const paused = cloudWorkspaces.filter((ws) => ws.status === "paused"); + const stopped = cloudWorkspaces.filter((ws) => ws.status === "stopped"); + const provisioning = cloudWorkspaces.filter( + (ws) => ws.status === "provisioning", + ); + const error = cloudWorkspaces.filter((ws) => ws.status === "error"); + + return { running, paused, stopped, provisioning, error, all: cloudWorkspaces }; +} + +export type CloudWorkspace = SelectCloudWorkspace; diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx index 837c9438569..3a77bfa5bb4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx @@ -1,10 +1,20 @@ import { createContext, type ReactNode, useContext, useMemo } from "react"; import { trpc } from "renderer/lib/trpc"; -import { getCollections } from "./collections"; +import { + type ApiClient, + createApiClient, + getCollections, +} from "./collections"; type Collections = ReturnType; -const CollectionsContext = createContext(null); +interface CollectionsContextValue { + collections: Collections; + apiClient: ApiClient; + token: string; +} + +const CollectionsContext = createContext(null); export function CollectionsProvider({ children }: { children: ReactNode }) { const { data: authState } = trpc.auth.onAuthState.useSubscription(); @@ -12,17 +22,20 @@ export function CollectionsProvider({ children }: { children: ReactNode }) { const activeOrganizationId = authState?.session?.activeOrganizationId; const token = authState?.token; - const collections = useMemo(() => { + const contextValue = useMemo(() => { if (!token || !activeOrganizationId) { return null; } // Get cached collections for this org (or create if first time) - return getCollections(activeOrganizationId, token); + const collections = getCollections(activeOrganizationId, token); + const apiClient = createApiClient(token); + + return { collections, apiClient, token }; }, [token, activeOrganizationId]); // Show loading only on initial mount - if (!collections) { + if (!contextValue) { return (
@@ -31,16 +44,24 @@ export function CollectionsProvider({ children }: { children: ReactNode }) { } return ( - + {children} ); } -export function useCollections(): Collections { +function useCollectionsContext() { const context = useContext(CollectionsContext); if (!context) { throw new Error("useCollections must be used within CollectionsProvider"); } return context; } + +export function useCollections(): Collections { + return useCollectionsContext().collections; +} + +export function useApiClient(): ApiClient { + return useCollectionsContext().apiClient; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts index c06e644381e..5f4055c02f0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts @@ -1,5 +1,6 @@ import { snakeCamelMapper } from "@electric-sql/client"; import type { + SelectCloudWorkspace, SelectMember, SelectOrganization, SelectRepository, @@ -18,12 +19,28 @@ import superjson from "superjson"; const columnMapper = snakeCamelMapper(); const electricUrl = `${env.NEXT_PUBLIC_API_URL}/api/electric/v1/shape`; +// Re-export createApiClient for use in mutation hooks +export function createApiClient(token: string) { + return createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, + headers: { Authorization: `Bearer ${token}` }, + transformer: superjson, + }), + ], + }); +} + +export type ApiClient = ReturnType; + interface OrgCollections { tasks: Collection; taskStatuses: Collection; repositories: Collection; members: Collection; users: Collection; + cloudWorkspaces: Collection; } // Per-org collections cache @@ -32,18 +49,6 @@ const collectionsCache = new Map(); // Shared organizations collection (same for all orgs) let organizationsCollection: Collection | null = null; -function createApiClient(token: string) { - return createTRPCProxyClient({ - links: [ - httpBatchLink({ - url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, - headers: { Authorization: `Bearer ${token}` }, - transformer: superjson, - }), - ], - }); -} - function createOrgCollections( organizationId: string, token: string, @@ -159,7 +164,43 @@ function createOrgCollections( }), ); - return { tasks, taskStatuses, repositories, members, users }; + const cloudWorkspaces = createCollection( + electricCollectionOptions({ + id: `cloud_workspaces-${organizationId}`, + shapeOptions: { + url: electricUrl, + params: { + table: "cloud_workspaces", + organizationId, + }, + headers, + columnMapper, + }, + getKey: (item) => item.id, + onInsert: async ({ transaction }) => { + const item = transaction.mutations[0].modified; + const result = await apiClient.cloudWorkspace.create.mutate(item); + return { txid: result.txid }; + }, + onUpdate: async ({ transaction }) => { + const { original, changes } = transaction.mutations[0]; + const result = await apiClient.cloudWorkspace.update.mutate({ + ...changes, + id: original.id, + }); + return { txid: result.txid }; + }, + onDelete: async ({ transaction }) => { + const item = transaction.mutations[0].original; + const result = await apiClient.cloudWorkspace.delete.mutate({ + workspaceId: item.id, + }); + return { txid: result.txid }; + }, + }), + ); + + return { tasks, taskStatuses, repositories, members, users, cloudWorkspaces }; } function getOrCreateOrganizationsCollection( diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/index.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/index.ts index 8200e98b680..c9a1a80a253 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/index.ts @@ -1 +1,5 @@ -export { CollectionsProvider, useCollections } from "./CollectionsProvider"; +export { + CollectionsProvider, + useApiClient, + useCollections, +} from "./CollectionsProvider"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspaceSection/CloudWorkspaceSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspaceSection/CloudWorkspaceSection.tsx new file mode 100644 index 00000000000..3e9ba95aa60 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspaceSection/CloudWorkspaceSection.tsx @@ -0,0 +1,143 @@ +import type { SelectCloudWorkspace } from "@superset/db/schema"; +import { AnimatePresence, motion } from "framer-motion"; +import { useState } from "react"; +import { HiChevronDown, HiOutlineCloud, HiOutlinePlus } from "react-icons/hi2"; +import { CloudWorkspaceListItem } from "renderer/components/CloudWorkspace"; +import { + useCloudWorkspaces, + useCloudWorkspacesByStatus, +} from "renderer/react-query/cloud-workspaces"; +import { useOpenCloudWorkspaceModal } from "renderer/stores/cloud-workspace-modal"; + +interface CloudWorkspaceSectionProps { + isCollapsed?: boolean; +} + +export function CloudWorkspaceSection({ + isCollapsed = false, +}: CloudWorkspaceSectionProps) { + const [isSectionCollapsed, setIsSectionCollapsed] = useState(false); + const cloudWorkspaces = useCloudWorkspaces(); + const { running, provisioning } = useCloudWorkspacesByStatus(); + const openModal = useOpenCloudWorkspaceModal(); + + // Don't render if no cloud workspaces and sidebar is collapsed + if (cloudWorkspaces.length === 0 && isCollapsed) { + return null; + } + + const activeCount = running.length + provisioning.length; + + const handleConnect = (workspaceId: string) => { + // TODO: Implement cloud workspace connection + console.log("[cloud-workspace] Connect to workspace:", workspaceId); + }; + + if (isCollapsed) { + return ( +
+ + + {!isSectionCollapsed && cloudWorkspaces.length > 0 && ( + +
+ {cloudWorkspaces.map((workspace: SelectCloudWorkspace) => ( + handleConnect(workspace.id)} + /> + ))} +
+
+ )} +
+
+ ); + } + + return ( +
+ {/* Header */} +
+ + +
+ + {/* Workspace List */} + + {!isSectionCollapsed && ( + +
+ {cloudWorkspaces.length === 0 ? ( +
+

+ No cloud workspaces yet +

+ +
+ ) : ( + cloudWorkspaces.map((workspace: SelectCloudWorkspace) => ( + handleConnect(workspace.id)} + /> + )) + )} +
+
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspaceSection/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspaceSection/index.ts new file mode 100644 index 00000000000..b1c08d8eeb6 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspaceSection/index.ts @@ -0,0 +1 @@ +export { CloudWorkspaceSection } from "./CloudWorkspaceSection"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx index 2515bdfc862..91cab027a3d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { useWorkspaceShortcuts } from "renderer/hooks/useWorkspaceShortcuts"; +import { CloudWorkspaceSection } from "./CloudWorkspaceSection"; import { PortsList } from "./PortsList"; import { ProjectSection } from "./ProjectSection"; import { WorkspaceSidebarFooter } from "./WorkspaceSidebarFooter"; @@ -32,6 +33,10 @@ export function WorkspaceSidebar({
+ {/* Cloud Workspaces Section */} + + + {/* Local Workspaces by Project */} {groups.map((group, index) => ( + ); diff --git a/apps/desktop/src/renderer/stores/cloud-workspace-modal.ts b/apps/desktop/src/renderer/stores/cloud-workspace-modal.ts new file mode 100644 index 00000000000..41568654ba8 --- /dev/null +++ b/apps/desktop/src/renderer/stores/cloud-workspace-modal.ts @@ -0,0 +1,39 @@ +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + +interface CloudWorkspaceModalState { + isOpen: boolean; + preSelectedRepositoryId: string | null; + openModal: (repositoryId?: string) => void; + closeModal: () => void; +} + +export const useCloudWorkspaceModalStore = create()( + devtools( + (set) => ({ + isOpen: false, + preSelectedRepositoryId: null, + openModal: (repositoryId) => + set({ + isOpen: true, + preSelectedRepositoryId: repositoryId ?? null, + }), + closeModal: () => + set({ + isOpen: false, + preSelectedRepositoryId: null, + }), + }), + { name: "CloudWorkspaceModalStore" }, + ), +); + +// Convenience selectors +export const useCloudWorkspaceModalOpen = () => + useCloudWorkspaceModalStore((state) => state.isOpen); + +export const useOpenCloudWorkspaceModal = () => + useCloudWorkspaceModalStore((state) => state.openModal); + +export const useCloseCloudWorkspaceModal = () => + useCloudWorkspaceModalStore((state) => state.closeModal); diff --git a/bun.lock b/bun.lock index 2ded206b2f4..7c363cfe90c 100644 --- a/bun.lock +++ b/bun.lock @@ -122,7 +122,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.53", + "version": "0.0.54", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -176,6 +176,7 @@ "fast-glob": "^3.3.3", "file-uri-to-path": "^1.0.0", "framer-motion": "^12.23.26", + "freestyle-sandboxes": "^0.1.3", "fuse.js": "^7.1.0", "http-proxy": "^1.18.1", "idb": "^8.0.3", @@ -470,6 +471,7 @@ "@vercel/blob": "^2.0.0", "@vercel/kv": "^3.0.0", "drizzle-orm": "0.45.1", + "freestyle-sandboxes": "^0.1.3", "superjson": "^2.2.5", "zod": "^4.1.13", }, @@ -2391,6 +2393,8 @@ "framer-motion": ["framer-motion@12.23.26", "", { "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA=="], + "freestyle-sandboxes": ["freestyle-sandboxes@0.1.6", "", {}, "sha512-zfyJy+DgmheFjCAPYMklo7rpzvuxNP46rB0a9WfNBEmitYGE23nlbjyTy8qdrmVuCVCoMIDQQzzJRkyuh0Szqg=="], + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], diff --git a/docs/CLOUD_WORKSPACE_IMPLEMENTATION.md b/docs/CLOUD_WORKSPACE_IMPLEMENTATION.md new file mode 100644 index 00000000000..1ae20e4adb6 --- /dev/null +++ b/docs/CLOUD_WORKSPACE_IMPLEMENTATION.md @@ -0,0 +1,446 @@ +# Cloud Workspace Implementation Plan + +> **Status**: Ready for Implementation +> **Last Updated**: 2026-01-13 + +## Overview + +Enable developers to work on remote VMs accessible from any device. Cloud VM is source of truth, GitHub is persistent storage. V1 Goal: Desktop app can create and connect to cloud-enabled worktrees. + +### Key Decisions +- **Provider**: Freestyle.dev (real implementation) +- **Git Sync**: Polling-based (cloud VM polls GitHub periodically) +- **Local Sync**: Electric SQL for real-time cloud workspace visibility in desktop + +### Environment Variables Required +``` +FREESTYLE_API_KEY=your_api_key +``` + +--- + +## Phase 1: Database Schema + +### 1.1 Add enums to `packages/db/src/schema/enums.ts` + +```typescript +// Cloud workspace status +export const cloudWorkspaceStatusValues = ["provisioning", "running", "paused", "stopped", "error"] as const; +export const cloudWorkspaceStatusEnum = z.enum(cloudWorkspaceStatusValues); +export type CloudWorkspaceStatus = z.infer; + +// Cloud provider type +export const cloudProviderTypeValues = ["freestyle", "fly"] as const; +export const cloudProviderTypeEnum = z.enum(cloudProviderTypeValues); +export type CloudProviderType = z.infer; + +// Client type for sessions +export const cloudClientTypeValues = ["desktop", "web"] as const; +export const cloudClientTypeEnum = z.enum(cloudClientTypeValues); +export type CloudClientType = z.infer; +``` + +### 1.2 Create `packages/db/src/schema/cloud-workspace.ts` + +**CloudWorkspaces table:** +- `id` (uuid, pk) +- `organizationId` (uuid, fk → organizations, cascade) +- `repositoryId` (uuid, fk → repositories, cascade) +- `name` (text) +- `branch` (text) +- `providerType` (enum: freestyle, fly) +- `providerVmId` (text, nullable) +- `status` (enum: provisioning, running, paused, stopped, error) +- `statusMessage` (text, nullable) +- `creatorId` (uuid, fk → users, cascade) +- `autoStopMinutes` (int, default 30) +- `lastActiveAt` (timestamp) +- `createdAt`, `updatedAt` (timestamps) + +**CloudWorkspaceSessions table:** +- `id` (uuid, pk) +- `workspaceId` (uuid, fk → cloudWorkspaces, cascade) +- `userId` (uuid, fk → users, cascade) +- `clientType` (enum: desktop, web) +- `connectedAt`, `lastHeartbeatAt` (timestamps) + +### 1.3 Update relations in `packages/db/src/schema/relations.ts` + +Add relations for cloudWorkspaces and cloudWorkspaceSessions. + +### 1.4 Export from `packages/db/src/schema/index.ts` + +### Files to modify: +- `packages/db/src/schema/enums.ts` +- `packages/db/src/schema/cloud-workspace.ts` (new) +- `packages/db/src/schema/relations.ts` +- `packages/db/src/schema/index.ts` + +--- + +## Phase 2: Cloud Provider Interface + Freestyle + +### 2.1 Create `packages/trpc/src/lib/cloud-providers/types.ts` + +```typescript +export interface SSHCredentials { + host: string; + port: number; + username: string; + privateKey?: string; + token?: string; +} + +export interface CreateVMParams { + repoUrl: string; + branch: string; + workspaceName: string; + workdir?: string; + idleTimeoutSeconds?: number; +} + +export interface VMStatus { + status: CloudWorkspaceStatus; + message?: string; +} + +export interface CloudProviderInterface { + readonly type: CloudProviderType; + createVM(params: CreateVMParams): Promise<{ vmId: string; status: CloudWorkspaceStatus }>; + pauseVM(vmId: string): Promise; + resumeVM(vmId: string): Promise; + stopVM(vmId: string): Promise; + deleteVM(vmId: string): Promise; + getVMStatus(vmId: string): Promise; + getSSHCredentials(vmId: string): Promise; +} +``` + +### 2.2 Create `packages/trpc/src/lib/cloud-providers/freestyle-provider.ts` + +Freestyle.dev SDK integration using their v2 API: + +```typescript +import Freestyle from "freestyle-sh"; + +const freestyle = new Freestyle(); // Uses FREESTYLE_API_KEY env var + +export class FreestyleProvider implements CloudProviderInterface { + readonly type = "freestyle" as const; + + async createVM(params: CreateVMParams) { + // Freestyle v2 API: create VM with git repo cloning + const { vmId, vm } = await freestyle.vms.create({ + gitRepos: [{ repo: params.repoUrl, path: "/workspace", branch: params.branch }], + workdir: "/workspace", + idleTimeoutSeconds: params.idleTimeoutSeconds ?? 1800, // 30 min default + with: { + js: new VmNodeJs(), // Enable Node.js runtime + }, + }); + return { vmId, status: "running" as const }; + } + + async pauseVM(vmId: string) { + // Freestyle: suspend_vm + await freestyle.vms.suspend({ vmId }); + return { status: "paused" as const }; + } + + async resumeVM(vmId: string) { + // Freestyle: start_vm (resumes from suspended) + await freestyle.vms.start({ vmId }); + return { status: "running" as const }; + } + + async stopVM(vmId: string) { + // Freestyle: stop_vm (graceful shutdown) + await freestyle.vms.stop({ vmId }); + return { status: "stopped" as const }; + } + + async deleteVM(vmId: string) { + // Freestyle: delete_vm + await freestyle.vms.delete({ vmId }); + } + + async getVMStatus(vmId: string) { + // Freestyle: get_vm + const vm = await freestyle.vms.get({ vmId }); + return { + status: this.mapFreestyleStatus(vm.status), + message: vm.statusMessage, + }; + } + + async getSSHCredentials(vmId: string) { + // Freestyle terminal/SSH access + const terminals = await freestyle.vms.listTerminals({ vmId }); + // Return SSH connection info from Freestyle + return { + host: terminals[0]?.host ?? "", + port: terminals[0]?.port ?? 22, + username: "dev", + token: terminals[0]?.accessToken, + }; + } + + private mapFreestyleStatus(status: string): CloudWorkspaceStatus { + const statusMap: Record = { + "running": "running", + "suspended": "paused", + "stopped": "stopped", + "starting": "provisioning", + "error": "error", + }; + return statusMap[status] ?? "error"; + } +} +``` + +**Key Freestyle v2 SDK methods used:** +- `freestyle.vms.create()` - Create VM with git repo cloning +- `freestyle.vms.suspend()` - Pause VM (preserves state) +- `freestyle.vms.start()` - Resume suspended VM +- `freestyle.vms.stop()` - Graceful shutdown +- `freestyle.vms.delete()` - Delete VM permanently +- `freestyle.vms.get()` - Get VM status +- `freestyle.vms.listTerminals()` - Get SSH/terminal access info + +### 2.3 Create `packages/trpc/src/lib/cloud-providers/index.ts` + +Factory function to get provider by type. + +```typescript +import type { CloudProviderType } from "@superset/db/enums"; +import { FreestyleProvider } from "./freestyle-provider"; +import type { CloudProviderInterface } from "./types"; + +export function getCloudProvider(type: CloudProviderType): CloudProviderInterface { + switch (type) { + case "freestyle": + return new FreestyleProvider(); + case "fly": + throw new Error("Fly provider not yet implemented"); + default: + throw new Error(`Unknown provider: ${type}`); + } +} + +export * from "./types"; +export { FreestyleProvider } from "./freestyle-provider"; +``` + +### 2.4 Install Freestyle SDK + +```bash +bun add freestyle-sh +``` + +### Files to create: +- `packages/trpc/src/lib/cloud-providers/types.ts` +- `packages/trpc/src/lib/cloud-providers/freestyle-provider.ts` +- `packages/trpc/src/lib/cloud-providers/index.ts` + +--- + +## Phase 3: tRPC Router + +### 3.1 Create `packages/trpc/src/router/cloud-workspace/schema.ts` + +Zod schemas for: +- `createCloudWorkspaceSchema` (organizationId, repositoryId, name, branch, providerType, autoStopMinutes) +- `cloudWorkspaceIdSchema` (workspaceId) +- `joinSessionSchema` (workspaceId, clientType) +- `heartbeatSchema` (sessionId) + +### 3.2 Create `packages/trpc/src/router/cloud-workspace/cloud-workspace.ts` + +**Query procedures:** +- `list` - List cloud workspaces for org +- `get` - Get single workspace with relations +- `getSSHCredentials` - Get SSH connection info + +**Mutation procedures:** +- `create` - Create workspace, start async provisioning +- `pause` / `resume` / `stop` / `delete` - Lifecycle operations +- `join` / `leave` / `heartbeat` - Session management + +### 3.3 Register in `packages/trpc/src/root.ts` + +Add `cloudWorkspace: cloudWorkspaceRouter` to appRouter. + +### Files to create/modify: +- `packages/trpc/src/router/cloud-workspace/schema.ts` (new) +- `packages/trpc/src/router/cloud-workspace/cloud-workspace.ts` (new) +- `packages/trpc/src/router/cloud-workspace/index.ts` (new) +- `packages/trpc/src/root.ts` (add router) + +--- + +## Phase 4: Local DB + Electric SQL Sync + +### 4.1 Add synced table to `packages/local-db/src/schema/schema.ts` + +Add `cloudWorkspaces` table (synced via Electric SQL): +```typescript +export const cloudWorkspaces = sqliteTable("cloud_workspaces", { + id: text("id").primaryKey(), + organization_id: text("organization_id").notNull(), + repository_id: text("repository_id").notNull(), + name: text("name").notNull(), + branch: text("branch").notNull(), + provider_type: text("provider_type").notNull(), + provider_vm_id: text("provider_vm_id"), + status: text("status").notNull(), + status_message: text("status_message"), + creator_id: text("creator_id").notNull(), + auto_stop_minutes: integer("auto_stop_minutes").notNull(), + last_active_at: text("last_active_at"), + created_at: text("created_at").notNull(), + updated_at: text("updated_at").notNull(), +}); +``` + +### 4.2 Add cloud link to workspaces table + +Add fields to existing `workspaces` table: +- `cloudWorkspaceId` (text, nullable) - Link to cloud workspace +- `cloudSyncEnabled` (boolean, default false) + +### 4.3 Create migration + +`packages/local-db/drizzle/0008_add_cloud_workspace.sql`: +```sql +-- Add cloud_workspaces synced table +CREATE TABLE cloud_workspaces (...); + +-- Add cloud fields to workspaces +ALTER TABLE workspaces ADD COLUMN cloud_workspace_id TEXT; +ALTER TABLE workspaces ADD COLUMN cloud_sync_enabled INTEGER DEFAULT 0; +``` + +### 4.4 Configure Electric SQL sync + +Add cloud_workspaces to Electric SQL shape configuration. + +### Files to modify: +- `packages/local-db/src/schema/schema.ts` +- `packages/local-db/drizzle/0008_add_cloud_workspace.sql` (new) +- Electric SQL config (location TBD based on existing setup) + +--- + +## Phase 5: Desktop SSH Terminal + +### 5.1 Create `apps/desktop/src/main/lib/ssh-terminal/ssh-manager.ts` + +SSH session manager using node-pty: +- `createSSHSession(paneId, credentials)` - Spawn SSH process +- `write(paneId, data)` - Send input +- `resize(paneId, cols, rows)` - Resize terminal +- `kill(paneId)` - Terminate session +- Events: `data:${paneId}`, `exit:${paneId}` + +### 5.2 Create `apps/desktop/src/lib/trpc/routers/cloud-terminal/index.ts` + +tRPC router for cloud terminal: +- `createSSHSession` mutation +- `write` mutation +- `resize` mutation +- `kill` mutation +- `stream` subscription (observable pattern) + +### 5.3 Register in desktop tRPC + +Add cloud-terminal router to desktop app router. + +### Files to create: +- `apps/desktop/src/main/lib/ssh-terminal/ssh-manager.ts` +- `apps/desktop/src/main/lib/ssh-terminal/index.ts` +- `apps/desktop/src/lib/trpc/routers/cloud-terminal/index.ts` + +--- + +## Phase 6: Desktop UI Integration + +### 6.1 Cloud workspace queries + +Add hooks to query cloud workspaces from local DB: +- `useCloudWorkspaces(organizationId)` +- `useCloudWorkspace(workspaceId)` + +### 6.2 "Enable Cloud" action + +Add context menu action on existing worktrees: +1. Right-click worktree → "Enable Cloud Workspace" +2. Call `cloudWorkspace.create` via API client +3. Update local workspace with `cloudWorkspaceId` + +### 6.3 "New Cloud Workspace" flow + +Extend NewWorkspaceModal: +1. Add "Cloud" option to workspace type selector +2. Show cloud-specific options (auto-stop timer) +3. Create both local workspace and cloud workspace + +### 6.4 Cloud terminal pane + +When workspace has `cloudWorkspaceId`: +1. Terminal pane fetches SSH credentials via API +2. Creates SSH session instead of local PTY +3. Shows cloud indicator in terminal header + +### 6.5 Status indicators + +- Show cloud workspace status badge (running/paused/stopped) +- Show "Enable Cloud" button for local-only workspaces +- Show connected users count for cloud workspaces + +### Files to modify: +- `apps/desktop/src/renderer/components/NewWorkspaceModal/` +- `apps/desktop/src/renderer/components/WorkspaceSidebar/` (context menu) +- `apps/desktop/src/renderer/components/Terminal/` (cloud terminal support) +- `apps/desktop/src/renderer/react-query/` (new hooks) + +--- + +## Implementation Order + +1. **Phase 1**: Database schema (foundation) +2. **Phase 2**: Cloud provider interface + Freestyle +3. **Phase 3**: tRPC router (API layer) +4. **Phase 4**: Local DB + Electric SQL sync +5. **Phase 5**: SSH terminal manager +6. **Phase 6**: Desktop UI integration + +--- + +## Testing Checklist + +- [ ] Create cloud workspace from UI +- [ ] View cloud workspace status updates +- [ ] Connect to cloud terminal (SSH) +- [ ] Pause/resume workspace lifecycle +- [ ] Delete cloud workspace +- [ ] Electric SQL syncs cloud workspaces to local DB +- [ ] Session heartbeat keeps workspace active + +--- + +## Deferred to V2 + +- Web terminal (xterm.js + WebSocket) +- GitHub webhook auto-pull (replacing polling) +- Environment variables/secrets management +- Cost tracking and workspace limits +- Multi-user presence indicators +- Fly.io provider implementation + +--- + +## Sources + +- [Freestyle.dev Docs](https://docs.freestyle.sh/) +- [Freestyle VM Documentation](https://docs.freestyle.sh/v2/vms.md) +- [Freestyle SDK Patterns](https://docs.freestyle.sh/v2/sdk-patterns.md) diff --git a/docs/CLOUD_WORKSPACE_PLAN.md b/docs/CLOUD_WORKSPACE_PLAN.md new file mode 100644 index 00000000000..70652b7b9a9 --- /dev/null +++ b/docs/CLOUD_WORKSPACE_PLAN.md @@ -0,0 +1,258 @@ +# Cloud Workspace Plan + +> **Status**: Ready for Implementation +> **Last Updated**: 2026-01-13 +> **Implementation Details**: See [CLOUD_WORKSPACE_IMPLEMENTATION.md](./CLOUD_WORKSPACE_IMPLEMENTATION.md) + +--- + +## Vision + +Cloud Workspaces enable developers to work on remote VMs that can be accessed from any device. The cloud VM is the source of truth for active development, while GitHub remains persistent storage. + +**V1 Goal**: Desktop app can create and connect to cloud-enabled worktrees. + +--- + +## Architecture + +``` + ┌─────────────────────────────────────┐ + │ GitHub │ + │ (Persistent Code Storage) │ + └────────────────┬────────────────────┘ + │ + git push/pull + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ CLOUD WORKSPACE │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Cloud VM (via Provider) │ │ +│ │ /workspace/.git, src/, ... │ │ +│ │ SOURCE OF TRUTH for active development │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ SSH Access │ +│ │ │ +└────────────────────────────────────┼─────────────────────────────────────┘ + │ + ▼ + ┌──────────┐ + │ Desktop │ + │ SSH via │ + │ node-pty │ + │ + local │ + │ sync │ + └──────────┘ +``` + +### Cloud Provider Abstraction + +The system uses an abstraction layer so providers can be swapped: + +``` +CloudWorkspace API + │ + ▼ +CloudProviderInterface + - createVM(repo, branch) → vmId + - pauseVM(vmId) + - resumeVM(vmId) + - stopVM(vmId) + - deleteVM(vmId) + - getSSHCredentials(vmId) → { host, token } + │ + ├── FreestyleProvider (initial) + ├── FlyProvider (future) + └── ... +``` + +--- + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| **Cloud Provider** | Freestyle.dev (initial) | Sub-second VM startup, SSH support | +| **Provider Abstraction** | Yes | Swap providers without changing app code | +| **Source of Truth** | Cloud VM | Simplifies multi-device, no sync conflicts | +| **Persistent Storage** | GitHub | Standard git workflow | +| **Local Sync** | Git push/pull | Familiar workflow, no new tools | +| **Real-time Updates** | Electric SQL | Already implemented in codebase | +| **Access Control** | Org members | Any org member can access org workspaces | + +--- + +## Data Model + +### CloudWorkspace Table + +| Field | Type | Description | +|-------|------|-------------| +| id | uuid | Primary key | +| organizationId | uuid | FK to organization | +| repositoryId | uuid | FK to repository | +| name | string | User-defined name | +| branch | string | Git branch | +| providerType | enum | 'freestyle', 'fly', etc. | +| providerVmId | string | Provider's VM identifier | +| status | enum | provisioning, running, paused, stopped, error | +| statusMessage | string | Error details if status=error | +| creatorId | uuid | FK to user who created | +| autoStopMinutes | int | Idle timeout (default 30) | +| lastActiveAt | timestamp | Last activity | + +### CloudWorkspaceSession Table + +Tracks connected clients for presence and activity. + +| Field | Type | Description | +|-------|------|-------------| +| id | uuid | Primary key | +| workspaceId | uuid | FK to workspace | +| userId | uuid | FK to user | +| clientType | enum | 'desktop', 'web' | +| connectedAt | timestamp | When connected | +| lastHeartbeatAt | timestamp | Last heartbeat | + +### Desktop Local DB + +Add to existing `workspaces` table: +- `cloudWorkspaceId` - Link to cloud workspace (null if local-only) +- `cloudSyncEnabled` - Whether syncing to cloud + +--- + +## State Machine + +``` + create() + │ + ▼ + ┌──────────────┐ + │ PROVISIONING │──────────┐ + └──────┬───────┘ │ failure + │ success ▼ + ▼ ┌──────────┐ + ┌──────────┐ │ ERROR │ + ┌─────▶│ RUNNING │ └────┬─────┘ + │ └────┬─────┘ │ retry() + │ │ pause()/timeout │ + │ ▼ │ + │ ┌──────────┐ │ + └──────│ PAUSED │◀───────────┘ + resume()/ └────┬─────┘ + connect() │ stop() + ▼ + ┌──────────┐ + │ STOPPED │ + └────┬─────┘ + │ delete() + ▼ + (removed) +``` + +**Key behaviors:** +- Auto-resume on connect to paused workspace +- Auto-pause after 30min idle +- Stopped workspaces persist until deleted + +--- + +## V1: Desktop Integration + +### User Flows + +**Flow 1: Convert Existing Worktree to Cloud** +1. User right-clicks existing worktree in sidebar +2. Selects "Enable Cloud Workspace" +3. System creates cloud VM, clones repo/branch +4. Worktree now has cloud terminal available +5. User can work locally (IDE) or on cloud (terminal) +6. Git push/pull syncs between local and cloud + +**Flow 2: Create New Cloud Worktree** +1. User clicks "New Workspace" → "Cloud Workspace" +2. Selects repository and branch +3. System creates cloud VM and local worktree +4. Both are linked and synced via git + +**Flow 3: Connect to Cloud Terminal** +1. User opens cloud-enabled worktree +2. Terminal pane shows cloud terminal (SSH) +3. Commands run on cloud VM +4. File edits happen locally, push to sync + +### Implementation Tasks + +**Database** +- Add `cloudWorkspaces` and `cloudWorkspaceSessions` tables +- Add `cloudWorkspaceId` to local workspaces table +- Set up Electric SQL sync for cloud workspace tables + +**Cloud Provider** +- Create `CloudProviderInterface` +- Implement `FreestyleProvider` +- Handle VM lifecycle (create, pause, resume, stop, delete) +- Handle SSH credential generation + +**tRPC Procedures** +- `cloudWorkspace.create` - Create cloud workspace +- `cloudWorkspace.get` / `list` - Query workspaces +- `cloudWorkspace.pause` / `resume` / `stop` / `delete` - Lifecycle +- `cloudWorkspace.getSSHCredentials` - Get connection info +- `cloudWorkspace.join` / `leave` / `heartbeat` - Session tracking + +**Desktop App** +- Use existing tRPC client for cloud workspace operations +- SSH terminal spawner using node-pty +- UI for "Enable Cloud" on existing worktree +- UI for "New Cloud Workspace" +- Cloud terminal view in workspace + +--- + +## Local Sync Workflow + +For users who want to edit locally with their IDE: + +``` +Local (IDE) GitHub Cloud VM + │ │ │ + │ ── git push ───▶ │ │ + │ │ ◀── auto-pull ─── │ (webhook or poll) + │ │ │ + │ ◀── git pull ─── │ ── git push ───▶ │ +``` + +- Local edits: commit, push to GitHub +- Cloud VM: auto-pulls on webhook or manual trigger +- Cloud edits: commit, push to GitHub +- Local: pull to get cloud changes + +--- + +## Future Work + +Items deferred from V1: + +- **Web Terminal** - xterm.js via WebSocket proxy to SSH +- **Command API** - REST endpoint for running commands (mobile, integrations) +- **Advanced Session Management** - Presence UI, multi-user indicators +- **VM Templates** - Pre-configured environments (Node, Python, etc.) +- **Cost Tracking** - Usage metrics per workspace +- **Workspace Limits** - Max concurrent per org + +--- + +## Resolved Decisions + +| Question | Decision | +|----------|----------| +| What triggers auto-pull on cloud VM? | **Polling** - VM polls GitHub periodically (webhooks deferred to V2) | +| Default VM specs? | Use Freestyle defaults (configurable via `idleTimeoutSeconds`) | + +## Open Questions + +1. How to handle environment variables/secrets in cloud workspaces? (Deferred to V2) diff --git a/docs/cloud-workspace-testing-plan.md b/docs/cloud-workspace-testing-plan.md new file mode 100644 index 00000000000..c9bd7561535 --- /dev/null +++ b/docs/cloud-workspace-testing-plan.md @@ -0,0 +1,236 @@ +# Cloud Workspace Testing Plan + +## Overview + +This document outlines the testing plan for the Cloud Workspace feature. The feature enables developers to work on remote VMs accessible from any device, with Freestyle.dev as the cloud provider. + +--- + +## Prerequisites + +### Environment Setup + +1. **Freestyle API Key** + ```bash + # Add to .env file at repository root + FREESTYLE_API_KEY=your_freestyle_api_key + ``` + +2. **Database Migration** + - Ensure migration `0011_add_cloud_workspaces.sql` has been applied + - Run `bun run db:migrate` if needed + +3. **Electric SQL Configuration** + - Verify cloud_workspaces table is included in Electric SQL shape configuration + - Confirm sync is working between cloud and local DB + +--- + +## Test Categories + +### 1. Database Schema Tests + +| Test Case | Steps | Expected Result | +|-----------|-------|-----------------| +| Cloud workspace creation | Insert a record into cloud_workspaces table | Record persists with correct field types | +| Foreign key constraints | Create workspace with invalid org/repo ID | Insert fails with foreign key violation | +| Cascade delete | Delete organization with cloud workspaces | All related cloud workspaces deleted | +| Status enum validation | Insert workspace with invalid status | Insert fails with enum constraint | + +### 2. Cloud Provider (Freestyle) Integration Tests + +| Test Case | Steps | Expected Result | +|-----------|-------|-----------------| +| Create VM | Call `freestyle.vms.create()` with valid params | VM created, returns vmId | +| Pause VM | Call `freestyle.vms.suspend()` on running VM | VM state changes to suspended | +| Resume VM | Call `freestyle.vms.start()` on paused VM | VM state changes to running | +| Stop VM | Call `freestyle.vms.stop()` on running VM | VM gracefully stops | +| Delete VM | Call `freestyle.vms.delete()` on stopped VM | VM permanently deleted | +| Get VM status | Call `freestyle.vms.get()` with vmId | Returns current VM status | +| Invalid API key | Make API call with invalid key | Returns authentication error | + +### 3. tRPC Router Tests + +#### Query Procedures + +| Test Case | Steps | Expected Result | +|-----------|-------|-----------------| +| List workspaces | Call `cloudWorkspace.list` with org ID | Returns array of workspaces for org | +| Get single workspace | Call `cloudWorkspace.get` with workspace ID | Returns workspace with relations | +| Get SSH credentials | Call `cloudWorkspace.getSSHCredentials` for running workspace | Returns host, port, username | +| Unauthorized access | Call endpoints without auth token | Returns UNAUTHORIZED error | + +#### Mutation Procedures + +| Test Case | Steps | Expected Result | +|-----------|-------|-----------------| +| Create workspace | Call `cloudWorkspace.create` with valid params | Creates record, triggers provisioning | +| Pause workspace | Call `cloudWorkspace.pause` on running workspace | Updates status to paused | +| Resume workspace | Call `cloudWorkspace.resume` on paused workspace | Updates status to running | +| Stop workspace | Call `cloudWorkspace.stop` on running/paused workspace | Updates status to stopped | +| Delete workspace | Call `cloudWorkspace.delete` on stopped workspace | Removes record, calls Freestyle delete | +| Invalid state transition | Pause a stopped workspace | Returns error, no state change | + +### 4. Electric SQL Sync Tests + +| Test Case | Steps | Expected Result | +|-----------|-------|-----------------| +| Initial sync | Open desktop app after backend creates workspace | Workspace appears in local DB | +| Real-time updates | Change workspace status via API | UI updates within seconds | +| Offline handling | Modify workspace while offline | Changes sync when back online | +| Multiple clients | Open app on two machines | Both see same workspace list | + +### 5. Desktop UI Tests + +#### Cloud Workspace Modal + +| Test Case | Steps | Expected Result | +|-----------|-------|-----------------| +| Open modal | Click "+ Cloud" button in sidebar | Modal opens with repository selector | +| Select repository | Choose repo from dropdown | Branch dropdown populates | +| Select branch | Choose branch | Workspace name auto-generates | +| Create workspace | Fill form, click Create | Modal closes, workspace appears in sidebar | +| Validation | Submit with empty fields | Create button stays disabled | +| Error handling | Create with API failure | Toast shows error message | + +#### Cloud Workspace List + +| Test Case | Steps | Expected Result | +|-----------|-------|-----------------| +| Empty state | No cloud workspaces exist | Shows "Create your first cloud workspace" | +| List display | Multiple workspaces exist | All workspaces shown with status badges | +| Status badge colors | Various workspace statuses | Correct color/icon for each status | +| Context menu | Right-click workspace | Shows available actions based on status | + +#### Workspace Actions + +| Test Case | Steps | Expected Result | +|-----------|-------|-----------------| +| Connect | Click Connect on running workspace | Terminal connects to remote | +| Pause | Click Pause in context menu | Status changes to paused, pause action disabled | +| Resume | Click Resume on paused workspace | Status changes to running | +| Stop | Click Stop in context menu | Status changes to stopped | +| Delete | Click Delete on stopped workspace | Workspace removed from list | +| Action availability | Check each status | Only valid actions are enabled | + +### 6. Cloud Terminal Tests + +| Test Case | Steps | Expected Result | +|-----------|-------|-----------------| +| Create session | Connect to running workspace | Terminal session established | +| Command execution | Run `ls -la` in terminal | Output displayed correctly | +| Terminal resize | Resize terminal pane | Remote terminal adjusts | +| Session persistence | Close/reopen terminal | Can reconnect to same session | +| Disconnect handling | Workspace stops while connected | Terminal shows disconnection | + +### 7. Session Management Tests + +| Test Case | Steps | Expected Result | +|-----------|-------|-----------------| +| Join session | Connect to workspace | Session record created | +| Heartbeat | Stay connected for 1+ minutes | lastHeartbeatAt updates | +| Leave session | Disconnect from workspace | Session record removed | +| Auto-stop | Leave workspace idle beyond timeout | Workspace auto-pauses | + +--- + +## Manual Testing Checklist + +### Happy Path Flow + +- [ ] Create a new cloud workspace from the sidebar +- [ ] Wait for provisioning to complete (status: running) +- [ ] Connect to the workspace terminal +- [ ] Run commands in the terminal +- [ ] Pause the workspace +- [ ] Resume the workspace +- [ ] Stop the workspace +- [ ] Delete the workspace + +### Edge Cases + +- [ ] Create workspace with long name (50+ chars) +- [ ] Create workspace for branch with special characters +- [ ] Handle Freestyle API rate limits +- [ ] Recover from network interruption during provisioning +- [ ] Handle concurrent operations on same workspace + +### Error Scenarios + +- [ ] Invalid Freestyle API key +- [ ] Freestyle service unavailable +- [ ] Repository access denied +- [ ] Workspace quota exceeded +- [ ] Network timeout during VM creation + +--- + +## Performance Tests + +| Test | Target | Measurement | +|------|--------|-------------| +| VM provisioning time | < 60 seconds | Time from create to running | +| Electric SQL sync latency | < 2 seconds | Time from API change to UI update | +| Terminal responsiveness | < 100ms | Keystroke to display latency | +| List load time | < 500ms | Time to render workspace list | + +--- + +## Security Tests + +| Test Case | Verification | +|-----------|--------------| +| Authentication | All endpoints require valid auth token | +| Authorization | Users can only access their org's workspaces | +| API key storage | Freestyle key not exposed in client | +| SSH credentials | Only returned for running workspaces | + +--- + +## Test Environment + +### Development +- Local PostgreSQL + Electric SQL +- Freestyle sandbox environment +- Mock SSH connections + +### Staging +- Neon branch database +- Freestyle production API (with test projects) +- Real SSH connections + +### Production +- Neon main database +- Freestyle production API +- Full monitoring enabled + +--- + +## Known Limitations (V1) + +1. **Terminal Output** - Currently polling-based (Freestyle SDK limitation). Full WebSocket support pending SDK update. + +2. **Git Sync** - Polling-based sync from GitHub. Real-time webhooks deferred to V2. + +3. **Provider Support** - Only Freestyle.dev supported. Fly.io deferred to V2. + +--- + +## Test Data Cleanup + +After testing: +```sql +-- Clean up test cloud workspaces +DELETE FROM cloud_workspaces WHERE name LIKE 'test-%'; + +-- Or use Freestyle dashboard to delete test VMs +``` + +--- + +## Reporting + +Test results should be documented in: +1. GitHub Issue for tracking +2. Team Slack channel for visibility +3. Post-implementation review meeting diff --git a/packages/db/src/schema/cloud-workspace.ts b/packages/db/src/schema/cloud-workspace.ts new file mode 100644 index 00000000000..bfb0f975f85 --- /dev/null +++ b/packages/db/src/schema/cloud-workspace.ts @@ -0,0 +1,102 @@ +import { + index, + integer, + pgEnum, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +import { organizations, users } from "./auth"; +import { + cloudClientTypeValues, + cloudProviderTypeValues, + cloudWorkspaceStatusValues, +} from "./enums"; +import { repositories } from "./schema"; + +export const cloudWorkspaceStatus = pgEnum( + "cloud_workspace_status", + cloudWorkspaceStatusValues, +); + +export const cloudProviderType = pgEnum( + "cloud_provider_type", + cloudProviderTypeValues, +); + +export const cloudClientType = pgEnum("cloud_client_type", cloudClientTypeValues); + +export const cloudWorkspaces = pgTable( + "cloud_workspaces", + { + id: uuid().primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + repositoryId: uuid("repository_id") + .notNull() + .references(() => repositories.id, { onDelete: "cascade" }), + name: text().notNull(), + branch: text().notNull(), + + // Provider info + providerType: cloudProviderType("provider_type").notNull().default("freestyle"), + providerVmId: text("provider_vm_id"), + + // State + status: cloudWorkspaceStatus().notNull().default("provisioning"), + statusMessage: text("status_message"), + + // Configuration + creatorId: uuid("creator_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + autoStopMinutes: integer("auto_stop_minutes").notNull().default(30), + + // Activity tracking + lastActiveAt: timestamp("last_active_at"), + + // Timestamps + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at") + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + index("cloud_workspaces_organization_id_idx").on(table.organizationId), + index("cloud_workspaces_repository_id_idx").on(table.repositoryId), + index("cloud_workspaces_creator_id_idx").on(table.creatorId), + index("cloud_workspaces_status_idx").on(table.status), + ], +); + +export type InsertCloudWorkspace = typeof cloudWorkspaces.$inferInsert; +export type SelectCloudWorkspace = typeof cloudWorkspaces.$inferSelect; + +export const cloudWorkspaceSessions = pgTable( + "cloud_workspace_sessions", + { + id: uuid().primaryKey().defaultRandom(), + workspaceId: uuid("workspace_id") + .notNull() + .references(() => cloudWorkspaces.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + clientType: cloudClientType("client_type").notNull(), + connectedAt: timestamp("connected_at").notNull().defaultNow(), + lastHeartbeatAt: timestamp("last_heartbeat_at").notNull().defaultNow(), + }, + (table) => [ + index("cloud_workspace_sessions_workspace_id_idx").on(table.workspaceId), + index("cloud_workspace_sessions_user_id_idx").on(table.userId), + ], +); + +export type InsertCloudWorkspaceSession = + typeof cloudWorkspaceSessions.$inferInsert; +export type SelectCloudWorkspaceSession = + typeof cloudWorkspaceSessions.$inferSelect; diff --git a/packages/db/src/schema/enums.ts b/packages/db/src/schema/enums.ts index d5cba4044f8..e93649a390a 100644 --- a/packages/db/src/schema/enums.ts +++ b/packages/db/src/schema/enums.ts @@ -26,3 +26,24 @@ export type TaskPriority = z.infer; export const integrationProviderValues = ["linear"] as const; export const integrationProviderEnum = z.enum(integrationProviderValues); export type IntegrationProvider = z.infer; + +// Cloud workspace status +export const cloudWorkspaceStatusValues = [ + "provisioning", + "running", + "paused", + "stopped", + "error", +] as const; +export const cloudWorkspaceStatusEnum = z.enum(cloudWorkspaceStatusValues); +export type CloudWorkspaceStatus = z.infer; + +// Cloud provider type +export const cloudProviderTypeValues = ["freestyle", "fly"] as const; +export const cloudProviderTypeEnum = z.enum(cloudProviderTypeValues); +export type CloudProviderType = z.infer; + +// Client type for sessions +export const cloudClientTypeValues = ["desktop", "web"] as const; +export const cloudClientTypeEnum = z.enum(cloudClientTypeValues); +export type CloudClientType = z.infer; diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 54196ae0b99..8f0206e23b8 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -1,4 +1,6 @@ export * from "./auth"; +export * from "./cloud-workspace"; +export * from "./enums"; export * from "./ingest"; export * from "./relations"; export * from "./schema"; diff --git a/packages/db/src/schema/relations.ts b/packages/db/src/schema/relations.ts index 2e9afd3210f..4122e85c108 100644 --- a/packages/db/src/schema/relations.ts +++ b/packages/db/src/schema/relations.ts @@ -8,6 +8,7 @@ import { sessions, users, } from "./auth"; +import { cloudWorkspaces, cloudWorkspaceSessions } from "./cloud-workspace"; import { integrationConnections, repositories, @@ -23,6 +24,8 @@ export const usersRelations = relations(users, ({ many }) => ({ createdTasks: many(tasks, { relationName: "creator" }), assignedTasks: many(tasks, { relationName: "assignee" }), connectedIntegrations: many(integrationConnections), + createdCloudWorkspaces: many(cloudWorkspaces), + cloudWorkspaceSessions: many(cloudWorkspaceSessions), })); export const sessionsRelations = relations(sessions, ({ one }) => ({ @@ -46,6 +49,7 @@ export const organizationsRelations = relations(organizations, ({ many }) => ({ tasks: many(tasks), taskStatuses: many(taskStatuses), integrations: many(integrationConnections), + cloudWorkspaces: many(cloudWorkspaces), })); export const membersRelations = relations(members, ({ one }) => ({ @@ -78,6 +82,7 @@ export const repositoriesRelations = relations( references: [organizations.id], }), tasks: many(tasks), + cloudWorkspaces: many(cloudWorkspaces), }), ); @@ -130,3 +135,36 @@ export const integrationConnectionsRelations = relations( }), }), ); + +export const cloudWorkspacesRelations = relations( + cloudWorkspaces, + ({ one, many }) => ({ + organization: one(organizations, { + fields: [cloudWorkspaces.organizationId], + references: [organizations.id], + }), + repository: one(repositories, { + fields: [cloudWorkspaces.repositoryId], + references: [repositories.id], + }), + creator: one(users, { + fields: [cloudWorkspaces.creatorId], + references: [users.id], + }), + sessions: many(cloudWorkspaceSessions), + }), +); + +export const cloudWorkspaceSessionsRelations = relations( + cloudWorkspaceSessions, + ({ one }) => ({ + workspace: one(cloudWorkspaces, { + fields: [cloudWorkspaceSessions.workspaceId], + references: [cloudWorkspaces.id], + }), + user: one(users, { + fields: [cloudWorkspaceSessions.userId], + references: [users.id], + }), + }), +); diff --git a/packages/local-db/drizzle/0011_add_cloud_workspaces.sql b/packages/local-db/drizzle/0011_add_cloud_workspaces.sql new file mode 100644 index 00000000000..1fb4bc7c5c5 --- /dev/null +++ b/packages/local-db/drizzle/0011_add_cloud_workspaces.sql @@ -0,0 +1,24 @@ +-- Add cloud_workspaces synced table (mirrored from cloud Postgres via Electric SQL) +CREATE TABLE `cloud_workspaces` ( + `id` text PRIMARY KEY NOT NULL, + `organization_id` text NOT NULL REFERENCES `organizations`(`id`) ON DELETE CASCADE, + `repository_id` text NOT NULL, + `name` text NOT NULL, + `branch` text NOT NULL, + `provider_type` text NOT NULL, + `provider_vm_id` text, + `status` text NOT NULL, + `status_message` text, + `creator_id` text NOT NULL REFERENCES `users`(`id`) ON DELETE CASCADE, + `auto_stop_minutes` integer NOT NULL DEFAULT 30, + `last_active_at` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL +); + +CREATE INDEX `cloud_workspaces_organization_id_idx` ON `cloud_workspaces` (`organization_id`); +CREATE INDEX `cloud_workspaces_creator_id_idx` ON `cloud_workspaces` (`creator_id`); +CREATE INDEX `cloud_workspaces_status_idx` ON `cloud_workspaces` (`status`); + +-- Add cloud workspace link to workspaces table +ALTER TABLE `workspaces` ADD `cloud_workspace_id` text; diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index 298886a93e4..d4845292695 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1768004449114, "tag": "0010_add_workspace_deleting_at", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1768250000000, + "tag": "0011_add_cloud_workspaces", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/local-db/src/schema/relations.ts b/packages/local-db/src/schema/relations.ts index d58548995f4..a289041c57b 100644 --- a/packages/local-db/src/schema/relations.ts +++ b/packages/local-db/src/schema/relations.ts @@ -1,5 +1,12 @@ import { relations } from "drizzle-orm"; -import { projects, workspaces, worktrees } from "./schema"; +import { + cloudWorkspaces, + organizations, + projects, + users, + workspaces, + worktrees, +} from "./schema"; export const projectsRelations = relations(projects, ({ many }) => ({ worktrees: many(worktrees), @@ -23,4 +30,23 @@ export const workspacesRelations = relations(workspaces, ({ one }) => ({ fields: [workspaces.worktreeId], references: [worktrees.id], }), + cloudWorkspace: one(cloudWorkspaces, { + fields: [workspaces.cloudWorkspaceId], + references: [cloudWorkspaces.id], + }), })); + +export const cloudWorkspacesRelations = relations( + cloudWorkspaces, + ({ one, many }) => ({ + organization: one(organizations, { + fields: [cloudWorkspaces.organization_id], + references: [organizations.id], + }), + creator: one(users, { + fields: [cloudWorkspaces.creator_id], + references: [users.id], + }), + workspaces: many(workspaces), + }), +); diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index 5a96f81d142..da730208c48 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -106,6 +106,9 @@ export const workspaces = sqliteTable( // Timestamp when deletion was initiated. Non-null means deletion in progress. // Workspaces with deletingAt set should be filtered out from queries. deletingAt: integer("deleting_at"), + // Link to cloud workspace (synced via Electric SQL) + // When set, this workspace is backed by a cloud VM + cloudWorkspaceId: text("cloud_workspace_id"), }, (table) => [ index("workspaces_project_id_idx").on(table.projectId), @@ -278,3 +281,47 @@ export const tasks = sqliteTable( export type InsertTask = typeof tasks.$inferInsert; export type SelectTask = typeof tasks.$inferSelect; + +export type CloudWorkspaceStatus = + | "provisioning" + | "running" + | "paused" + | "stopped" + | "error"; +export type CloudProviderType = "freestyle" | "fly"; + +/** + * Cloud workspaces table - synced from cloud + * Represents remote VMs that can be connected to from the desktop app + */ +export const cloudWorkspaces = sqliteTable( + "cloud_workspaces", + { + id: text("id").primaryKey(), + organization_id: text("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + repository_id: text("repository_id").notNull(), + name: text("name").notNull(), + branch: text("branch").notNull(), + provider_type: text("provider_type").notNull().$type(), + provider_vm_id: text("provider_vm_id"), + status: text("status").notNull().$type(), + status_message: text("status_message"), + creator_id: text("creator_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + auto_stop_minutes: integer("auto_stop_minutes").notNull().default(30), + last_active_at: text("last_active_at"), + created_at: text("created_at").notNull(), + updated_at: text("updated_at").notNull(), + }, + (table) => [ + index("cloud_workspaces_organization_id_idx").on(table.organization_id), + index("cloud_workspaces_creator_id_idx").on(table.creator_id), + index("cloud_workspaces_status_idx").on(table.status), + ], +); + +export type InsertCloudWorkspace = typeof cloudWorkspaces.$inferInsert; +export type SelectCloudWorkspace = typeof cloudWorkspaces.$inferSelect; diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 238b65aee4a..7667bfe79d5 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -28,6 +28,7 @@ "@vercel/blob": "^2.0.0", "@vercel/kv": "^3.0.0", "drizzle-orm": "0.45.1", + "freestyle-sandboxes": "^0.1.3", "superjson": "^2.2.5", "zod": "^4.1.13" }, diff --git a/packages/trpc/src/lib/cloud-providers/freestyle-provider.ts b/packages/trpc/src/lib/cloud-providers/freestyle-provider.ts new file mode 100644 index 00000000000..242091c88a3 --- /dev/null +++ b/packages/trpc/src/lib/cloud-providers/freestyle-provider.ts @@ -0,0 +1,171 @@ +import { freestyle } from "freestyle-sandboxes"; +import type { CloudWorkspaceStatus } from "@superset/db/schema"; +import type { + CloudProviderInterface, + CreateVMParams, + SSHCredentials, + VMStatus, +} from "./types"; + +/** + * Freestyle.dev cloud provider implementation + * + * Uses the freestyle-sandboxes SDK to manage cloud VMs. + * Requires FREESTYLE_API_KEY environment variable to be set. + * + * @see https://docs.freestyle.sh/v2/vms + */ +export class FreestyleProvider implements CloudProviderInterface { + readonly type = "freestyle" as const; + + async createVM( + params: CreateVMParams, + ): Promise<{ vmId: string; status: CloudWorkspaceStatus }> { + try { + const { vmId } = await freestyle.vms.create({ + gitRepos: [ + { + repo: params.repoUrl, + path: "/workspace", + // Use 'rev' for branch/tag/commit + rev: params.branch, + }, + ], + workdir: params.workdir ?? "/workspace", + // Convert minutes to seconds for Freestyle API + // Default 30 min = 1800 seconds + idleTimeoutSeconds: params.idleTimeoutSeconds ?? 1800, + }); + + return { + vmId, + status: "running", + }; + } catch (error) { + console.error("[cloud-providers/freestyle] Failed to create VM:", error); + throw error; + } + } + + async pauseVM(vmId: string): Promise { + try { + // Get VM reference and call suspend + const vm = freestyle.vms.ref({ vmId }); + await vm.suspend(); + return { status: "paused" }; + } catch (error) { + console.error("[cloud-providers/freestyle] Failed to pause VM:", error); + return { + status: "error", + message: error instanceof Error ? error.message : "Failed to pause VM", + }; + } + } + + async resumeVM(vmId: string): Promise { + try { + // Get VM reference and call start + const vm = freestyle.vms.ref({ vmId }); + await vm.start(); + return { status: "running" }; + } catch (error) { + console.error("[cloud-providers/freestyle] Failed to resume VM:", error); + return { + status: "error", + message: error instanceof Error ? error.message : "Failed to resume VM", + }; + } + } + + async stopVM(vmId: string): Promise { + try { + // Get VM reference and call stop + const vm = freestyle.vms.ref({ vmId }); + await vm.stop(); + return { status: "stopped" }; + } catch (error) { + console.error("[cloud-providers/freestyle] Failed to stop VM:", error); + return { + status: "error", + message: error instanceof Error ? error.message : "Failed to stop VM", + }; + } + } + + async deleteVM(vmId: string): Promise { + try { + await freestyle.vms.delete({ vmId }); + } catch (error) { + console.error("[cloud-providers/freestyle] Failed to delete VM:", error); + throw error; + } + } + + async getVMStatus(vmId: string): Promise { + try { + const vm = freestyle.vms.ref({ vmId }); + const info = await vm.getInfo(); + + return { + status: this.mapFreestyleStatus(info.state ?? "unknown"), + }; + } catch (error) { + console.error( + "[cloud-providers/freestyle] Failed to get VM status:", + error, + ); + return { + status: "error", + message: error instanceof Error ? error.message : "Failed to get status", + }; + } + } + + async getSSHCredentials(vmId: string): Promise { + try { + // Get VM reference for terminal access + const vm = freestyle.vms.ref({ vmId }); + + // Get terminal list - Freestyle VMs use websocket-based terminal access + const terminalInfo = await vm.terminals.list(); + + if (!terminalInfo.terminals || terminalInfo.terminals.length === 0) { + throw new Error("No terminal sessions available for this VM"); + } + + // Freestyle uses websocket-based terminal access, not traditional SSH + // We return connection info that can be used with their terminal API + // The host would be the VM domain, accessed via Freestyle's infrastructure + return { + // Freestyle VMs are accessed via their domain + host: `${vmId}.freestyle.sh`, + port: 443, // Freestyle uses HTTPS/WSS + username: "dev", + // Token-based auth through Freestyle API + token: vmId, + }; + } catch (error) { + console.error( + "[cloud-providers/freestyle] Failed to get SSH credentials:", + error, + ); + throw error; + } + } + + /** + * Map Freestyle VM state to our CloudWorkspaceStatus enum + */ + private mapFreestyleStatus(state: string): CloudWorkspaceStatus { + const statusMap: Record = { + running: "running", + suspended: "paused", + stopped: "stopped", + starting: "provisioning", + provisioning: "provisioning", + error: "error", + failed: "error", + }; + return statusMap[state.toLowerCase()] ?? "error"; + } +} diff --git a/packages/trpc/src/lib/cloud-providers/index.ts b/packages/trpc/src/lib/cloud-providers/index.ts new file mode 100644 index 00000000000..c6c3f1ec4ae --- /dev/null +++ b/packages/trpc/src/lib/cloud-providers/index.ts @@ -0,0 +1,20 @@ +import type { CloudProviderType } from "@superset/db/schema"; +import { FreestyleProvider } from "./freestyle-provider"; +import type { CloudProviderInterface } from "./types"; + +/** + * Get a cloud provider instance by type + */ +export function getCloudProvider(type: CloudProviderType): CloudProviderInterface { + switch (type) { + case "freestyle": + return new FreestyleProvider(); + case "fly": + throw new Error("Fly provider not yet implemented"); + default: + throw new Error(`Unknown cloud provider: ${type}`); + } +} + +export * from "./types"; +export { FreestyleProvider } from "./freestyle-provider"; diff --git a/packages/trpc/src/lib/cloud-providers/types.ts b/packages/trpc/src/lib/cloud-providers/types.ts new file mode 100644 index 00000000000..6d3a855642c --- /dev/null +++ b/packages/trpc/src/lib/cloud-providers/types.ts @@ -0,0 +1,66 @@ +import type { + CloudProviderType, + CloudWorkspaceStatus, +} from "@superset/db/schema"; + +export interface SSHCredentials { + host: string; + port: number; + username: string; + privateKey?: string; + token?: string; +} + +export interface CreateVMParams { + repoUrl: string; + branch: string; + workspaceName: string; + workdir?: string; + idleTimeoutSeconds?: number; +} + +export interface VMStatus { + status: CloudWorkspaceStatus; + message?: string; +} + +export interface CloudProviderInterface { + readonly type: CloudProviderType; + + /** + * Create a new VM with the given repository cloned + */ + createVM( + params: CreateVMParams, + ): Promise<{ vmId: string; status: CloudWorkspaceStatus }>; + + /** + * Pause/suspend a VM (preserves state, can resume quickly) + */ + pauseVM(vmId: string): Promise; + + /** + * Resume a paused VM + */ + resumeVM(vmId: string): Promise; + + /** + * Stop a VM (graceful shutdown) + */ + stopVM(vmId: string): Promise; + + /** + * Delete a VM permanently + */ + deleteVM(vmId: string): Promise; + + /** + * Get current VM status + */ + getVMStatus(vmId: string): Promise; + + /** + * Get SSH credentials to connect to the VM + */ + getSSHCredentials(vmId: string): Promise; +} diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts index 6829cd677d8..99d0d30e488 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -2,6 +2,7 @@ import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; import { adminRouter } from "./router/admin"; import { analyticsRouter } from "./router/analytics"; +import { cloudWorkspaceRouter } from "./router/cloud-workspace"; import { integrationRouter } from "./router/integration"; import { organizationRouter } from "./router/organization"; import { repositoryRouter } from "./router/repository"; @@ -12,6 +13,7 @@ import { createCallerFactory, createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ admin: adminRouter, analytics: analyticsRouter, + cloudWorkspace: cloudWorkspaceRouter, integration: integrationRouter, organization: organizationRouter, repository: repositoryRouter, diff --git a/packages/trpc/src/router/cloud-workspace/cloud-workspace.ts b/packages/trpc/src/router/cloud-workspace/cloud-workspace.ts new file mode 100644 index 00000000000..5d84f001000 --- /dev/null +++ b/packages/trpc/src/router/cloud-workspace/cloud-workspace.ts @@ -0,0 +1,504 @@ +import { db, dbWs } from "@superset/db/client"; +import { + cloudWorkspaces, + cloudWorkspaceSessions, + repositories, +} from "@superset/db/schema"; +import { getCurrentTxid } from "@superset/db/utils"; +import type { TRPCRouterRecord } from "@trpc/server"; +import { TRPCError } from "@trpc/server"; +import { and, desc, eq } from "drizzle-orm"; +import { z } from "zod"; +import { getCloudProvider } from "../../lib/cloud-providers"; +import { protectedProcedure } from "../../trpc"; +import { verifyOrgMembership } from "../integration/linear/utils"; +import { + cloudWorkspaceIdSchema, + createCloudWorkspaceSchema, + heartbeatSchema, + joinSessionSchema, + leaveSessionSchema, + updateCloudWorkspaceSchema, +} from "./schema"; + +/** + * Helper to get workspace and verify org membership + */ +async function getWorkspaceWithAuth(userId: string, workspaceId: string) { + const workspace = await db.query.cloudWorkspaces.findFirst({ + where: eq(cloudWorkspaces.id, workspaceId), + }); + + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Cloud workspace not found", + }); + } + + await verifyOrgMembership(userId, workspace.organizationId); + + return workspace; +} + +/** + * Provision VM asynchronously (fire and forget) + */ +async function provisionVM( + workspaceId: string, + repoUrl: string, + input: z.infer, +) { + try { + const provider = getCloudProvider(input.providerType); + const result = await provider.createVM({ + repoUrl, + branch: input.branch, + workspaceName: input.name, + idleTimeoutSeconds: input.autoStopMinutes * 60, + }); + + await db + .update(cloudWorkspaces) + .set({ + providerVmId: result.vmId, + status: "running", + lastActiveAt: new Date(), + }) + .where(eq(cloudWorkspaces.id, workspaceId)); + + console.log( + `[cloud-workspace/provision] Successfully provisioned VM for workspace ${workspaceId}`, + ); + } catch (error) { + console.error("[cloud-workspace/provision] Failed to provision VM:", error); + await db + .update(cloudWorkspaces) + .set({ + status: "error", + statusMessage: + error instanceof Error ? error.message : "Unknown provisioning error", + }) + .where(eq(cloudWorkspaces.id, workspaceId)); + } +} + +export const cloudWorkspaceRouter = { + // ============================================================ + // Query Procedures + // ============================================================ + + /** + * List cloud workspaces for an organization + */ + list: protectedProcedure + .input(z.object({ organizationId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + await verifyOrgMembership(ctx.session.user.id, input.organizationId); + + return db.query.cloudWorkspaces.findMany({ + where: eq(cloudWorkspaces.organizationId, input.organizationId), + orderBy: desc(cloudWorkspaces.createdAt), + with: { + repository: true, + creator: true, + }, + }); + }), + + /** + * Get a single cloud workspace by ID + */ + get: protectedProcedure + .input(cloudWorkspaceIdSchema) + .query(async ({ ctx, input }) => { + const workspace = await db.query.cloudWorkspaces.findFirst({ + where: eq(cloudWorkspaces.id, input.workspaceId), + with: { + repository: true, + creator: true, + organization: true, + }, + }); + + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Cloud workspace not found", + }); + } + + await verifyOrgMembership(ctx.session.user.id, workspace.organizationId); + + return workspace; + }), + + /** + * Get SSH credentials for a running workspace + */ + getSSHCredentials: protectedProcedure + .input(cloudWorkspaceIdSchema) + .query(async ({ ctx, input }) => { + const workspace = await getWorkspaceWithAuth( + ctx.session.user.id, + input.workspaceId, + ); + + if (workspace.status !== "running") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Workspace is ${workspace.status}, must be running to get SSH credentials`, + }); + } + + if (!workspace.providerVmId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Workspace does not have a VM assigned yet", + }); + } + + const provider = getCloudProvider(workspace.providerType); + return provider.getSSHCredentials(workspace.providerVmId); + }), + + /** + * Get active sessions for a workspace + */ + getActiveSessions: protectedProcedure + .input(cloudWorkspaceIdSchema) + .query(async ({ ctx, input }) => { + await getWorkspaceWithAuth(ctx.session.user.id, input.workspaceId); + + return db.query.cloudWorkspaceSessions.findMany({ + where: eq(cloudWorkspaceSessions.workspaceId, input.workspaceId), + with: { + user: true, + }, + }); + }), + + // ============================================================ + // Mutation Procedures - CRUD + // ============================================================ + + /** + * Create a new cloud workspace + */ + create: protectedProcedure + .input(createCloudWorkspaceSchema) + .mutation(async ({ ctx, input }) => { + await verifyOrgMembership(ctx.session.user.id, input.organizationId); + + // Verify repository exists + const repository = await db.query.repositories.findFirst({ + where: eq(repositories.id, input.repositoryId), + }); + + if (!repository) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Repository not found", + }); + } + + const result = await dbWs.transaction(async (tx) => { + const [workspace] = await tx + .insert(cloudWorkspaces) + .values({ + ...input, + creatorId: ctx.session.user.id, + status: "provisioning", + }) + .returning(); + + const txid = await getCurrentTxid(tx); + return { workspace, txid }; + }); + + // Start async VM provisioning (fire and forget) + if (result.workspace) { + void provisionVM(result.workspace.id, repository.repoUrl, input); + } + + return result; + }), + + /** + * Update cloud workspace settings + */ + update: protectedProcedure + .input(updateCloudWorkspaceSchema) + .mutation(async ({ ctx, input }) => { + const { id, ...data } = input; + await getWorkspaceWithAuth(ctx.session.user.id, id); + + const result = await dbWs.transaction(async (tx) => { + const [workspace] = await tx + .update(cloudWorkspaces) + .set(data) + .where(eq(cloudWorkspaces.id, id)) + .returning(); + + const txid = await getCurrentTxid(tx); + return { workspace, txid }; + }); + + return result; + }), + + // ============================================================ + // Mutation Procedures - Lifecycle + // ============================================================ + + /** + * Pause a running workspace + */ + pause: protectedProcedure + .input(cloudWorkspaceIdSchema) + .mutation(async ({ ctx, input }) => { + const workspace = await getWorkspaceWithAuth( + ctx.session.user.id, + input.workspaceId, + ); + + if (workspace.status !== "running") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Cannot pause workspace in ${workspace.status} state`, + }); + } + + if (!workspace.providerVmId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Workspace does not have a VM assigned", + }); + } + + const provider = getCloudProvider(workspace.providerType); + const status = await provider.pauseVM(workspace.providerVmId); + + const result = await dbWs.transaction(async (tx) => { + const [updated] = await tx + .update(cloudWorkspaces) + .set({ + status: status.status, + statusMessage: status.message, + }) + .where(eq(cloudWorkspaces.id, input.workspaceId)) + .returning(); + + const txid = await getCurrentTxid(tx); + return { workspace: updated, txid }; + }); + + return result; + }), + + /** + * Resume a paused workspace + */ + resume: protectedProcedure + .input(cloudWorkspaceIdSchema) + .mutation(async ({ ctx, input }) => { + const workspace = await getWorkspaceWithAuth( + ctx.session.user.id, + input.workspaceId, + ); + + if (workspace.status !== "paused") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Cannot resume workspace in ${workspace.status} state`, + }); + } + + if (!workspace.providerVmId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Workspace does not have a VM assigned", + }); + } + + const provider = getCloudProvider(workspace.providerType); + const status = await provider.resumeVM(workspace.providerVmId); + + const result = await dbWs.transaction(async (tx) => { + const [updated] = await tx + .update(cloudWorkspaces) + .set({ + status: status.status, + statusMessage: status.message, + lastActiveAt: new Date(), + }) + .where(eq(cloudWorkspaces.id, input.workspaceId)) + .returning(); + + const txid = await getCurrentTxid(tx); + return { workspace: updated, txid }; + }); + + return result; + }), + + /** + * Stop a running workspace + */ + stop: protectedProcedure + .input(cloudWorkspaceIdSchema) + .mutation(async ({ ctx, input }) => { + const workspace = await getWorkspaceWithAuth( + ctx.session.user.id, + input.workspaceId, + ); + + if (workspace.status !== "running" && workspace.status !== "paused") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Cannot stop workspace in ${workspace.status} state`, + }); + } + + if (!workspace.providerVmId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Workspace does not have a VM assigned", + }); + } + + const provider = getCloudProvider(workspace.providerType); + const status = await provider.stopVM(workspace.providerVmId); + + const result = await dbWs.transaction(async (tx) => { + const [updated] = await tx + .update(cloudWorkspaces) + .set({ + status: status.status, + statusMessage: status.message, + }) + .where(eq(cloudWorkspaces.id, input.workspaceId)) + .returning(); + + const txid = await getCurrentTxid(tx); + return { workspace: updated, txid }; + }); + + return result; + }), + + /** + * Delete a cloud workspace permanently + */ + delete: protectedProcedure + .input(cloudWorkspaceIdSchema) + .mutation(async ({ ctx, input }) => { + const workspace = await getWorkspaceWithAuth( + ctx.session.user.id, + input.workspaceId, + ); + + // Delete VM from provider if it exists + if (workspace.providerVmId) { + try { + const provider = getCloudProvider(workspace.providerType); + await provider.deleteVM(workspace.providerVmId); + } catch (error) { + console.error( + "[cloud-workspace/delete] Failed to delete VM from provider:", + error, + ); + // Continue with DB deletion even if provider deletion fails + } + } + + const result = await dbWs.transaction(async (tx) => { + await tx + .delete(cloudWorkspaces) + .where(eq(cloudWorkspaces.id, input.workspaceId)); + + const txid = await getCurrentTxid(tx); + return { txid }; + }); + + return result; + }), + + // ============================================================ + // Mutation Procedures - Session Management + // ============================================================ + + /** + * Join a workspace session (auto-resumes if paused) + */ + join: protectedProcedure + .input(joinSessionSchema) + .mutation(async ({ ctx, input }) => { + const workspace = await getWorkspaceWithAuth( + ctx.session.user.id, + input.workspaceId, + ); + + // Auto-resume if paused + if (workspace.status === "paused" && workspace.providerVmId) { + try { + const provider = getCloudProvider(workspace.providerType); + await provider.resumeVM(workspace.providerVmId); + await db + .update(cloudWorkspaces) + .set({ + status: "running", + lastActiveAt: new Date(), + }) + .where(eq(cloudWorkspaces.id, input.workspaceId)); + } catch (error) { + console.error("[cloud-workspace/join] Failed to auto-resume:", error); + } + } + + // Create session + const [session] = await db + .insert(cloudWorkspaceSessions) + .values({ + workspaceId: input.workspaceId, + userId: ctx.session.user.id, + clientType: input.clientType, + }) + .returning(); + + // Update last active + await db + .update(cloudWorkspaces) + .set({ lastActiveAt: new Date() }) + .where(eq(cloudWorkspaces.id, input.workspaceId)); + + return { session }; + }), + + /** + * Leave a workspace session + */ + leave: protectedProcedure + .input(leaveSessionSchema) + .mutation(async ({ input }) => { + await db + .delete(cloudWorkspaceSessions) + .where(eq(cloudWorkspaceSessions.id, input.sessionId)); + + return { success: true }; + }), + + /** + * Send heartbeat for a session + */ + heartbeat: protectedProcedure + .input(heartbeatSchema) + .mutation(async ({ input }) => { + await db + .update(cloudWorkspaceSessions) + .set({ lastHeartbeatAt: new Date() }) + .where(eq(cloudWorkspaceSessions.id, input.sessionId)); + + return { success: true }; + }), +} satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/cloud-workspace/index.ts b/packages/trpc/src/router/cloud-workspace/index.ts new file mode 100644 index 00000000000..8aa2fa91a81 --- /dev/null +++ b/packages/trpc/src/router/cloud-workspace/index.ts @@ -0,0 +1 @@ +export { cloudWorkspaceRouter } from "./cloud-workspace"; diff --git a/packages/trpc/src/router/cloud-workspace/schema.ts b/packages/trpc/src/router/cloud-workspace/schema.ts new file mode 100644 index 00000000000..d4ebca65409 --- /dev/null +++ b/packages/trpc/src/router/cloud-workspace/schema.ts @@ -0,0 +1,37 @@ +import { + cloudClientTypeValues, + cloudProviderTypeValues, +} from "@superset/db/schema"; +import { z } from "zod"; + +export const createCloudWorkspaceSchema = z.object({ + organizationId: z.string().uuid(), + repositoryId: z.string().uuid(), + name: z.string().min(1), + branch: z.string().min(1), + providerType: z.enum(cloudProviderTypeValues).default("freestyle"), + autoStopMinutes: z.number().int().positive().default(30), +}); + +export const updateCloudWorkspaceSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1).optional(), + autoStopMinutes: z.number().int().positive().optional(), +}); + +export const cloudWorkspaceIdSchema = z.object({ + workspaceId: z.string().uuid(), +}); + +export const joinSessionSchema = z.object({ + workspaceId: z.string().uuid(), + clientType: z.enum(cloudClientTypeValues), +}); + +export const heartbeatSchema = z.object({ + sessionId: z.string().uuid(), +}); + +export const leaveSessionSchema = z.object({ + sessionId: z.string().uuid(), +});