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..80ddee6b739 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/cloud-terminal/index.ts @@ -0,0 +1,178 @@ +import { observable } from "@trpc/server/observable"; +import { sshManager } from "main/lib/ssh-terminal"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +const DEBUG_CLOUD_TERMINAL = process.env.SUPERSET_SSH_DEBUG === "1"; + +/** + * Cloud Terminal Router - manages SSH connections to cloud workspaces + * + * This router handles: + * - Creating SSH sessions to cloud VMs + * - Writing data to SSH sessions + * - Resizing terminals + * - Streaming terminal output + */ +export const createCloudTerminalRouter = () => { + return router({ + /** + * Create a new SSH session to a cloud workspace + */ + createSession: publicProcedure + .input( + z.object({ + paneId: z.string().min(1), + cloudWorkspaceId: z.string().uuid(), + credentials: z.object({ + host: z.string(), + port: z.number(), + username: z.string(), + token: z.string().optional(), + }), + cols: z.number().optional(), + rows: z.number().optional(), + }), + ) + .mutation(async ({ input }) => { + const { paneId, cloudWorkspaceId, credentials, cols, rows } = input; + + if (DEBUG_CLOUD_TERMINAL) { + console.log("[CloudTerminal] Creating session:", { + paneId, + cloudWorkspaceId, + host: credentials.host, + }); + } + + const result = await sshManager.createSession({ + paneId, + cloudWorkspaceId, + credentials, + cols, + rows, + }); + + return result; + }), + + /** + * Write data to SSH session + */ + write: publicProcedure + .input( + z.object({ + paneId: z.string(), + data: z.string(), + }), + ) + .mutation(({ input }) => { + try { + sshManager.write(input); + } catch (error) { + const message = + error instanceof Error ? error.message : "Write failed"; + + if (message.includes("not found or not alive")) { + sshManager.emit(`exit:${input.paneId}`, 0, 15); + return; + } + + sshManager.emit(`error:${input.paneId}`, { + error: message, + code: "WRITE_FAILED", + }); + } + }), + + /** + * Resize SSH terminal + */ + resize: publicProcedure + .input( + z.object({ + paneId: z.string(), + cols: z.number(), + rows: z.number(), + }), + ) + .mutation(({ input }) => { + sshManager.resize(input); + }), + + /** + * Kill SSH session + */ + kill: publicProcedure + .input( + z.object({ + paneId: z.string(), + }), + ) + .mutation(async ({ input }) => { + await sshManager.kill(input); + }), + + /** + * Get SSH session info + */ + getSession: publicProcedure.input(z.string()).query(({ input: paneId }) => { + return sshManager.getSession(paneId); + }), + + /** + * Kill all sessions for a cloud workspace + */ + killByCloudWorkspace: publicProcedure + .input(z.object({ cloudWorkspaceId: z.string().uuid() })) + .mutation(async ({ input }) => { + return sshManager.killByCloudWorkspaceId(input.cloudWorkspaceId); + }), + + /** + * Stream SSH terminal output + */ + stream: publicProcedure + .input(z.string()) + .subscription(({ input: paneId }) => { + return observable< + | { type: "data"; data: string } + | { type: "exit"; exitCode: number; signal?: number } + | { type: "error"; error: string; code?: string } + >((emit) => { + if (DEBUG_CLOUD_TERMINAL) { + console.log(`[CloudTerminal Stream] Subscribe: ${paneId}`); + } + + const onData = (data: string) => { + emit.next({ type: "data", data }); + }; + + const onExit = (exitCode: number, signal?: number) => { + emit.next({ type: "exit", exitCode, signal }); + }; + + const onError = (payload: { error: string; code?: string }) => { + emit.next({ + type: "error", + error: payload.error, + code: payload.code, + }); + }; + + sshManager.on(`data:${paneId}`, onData); + sshManager.on(`exit:${paneId}`, onExit); + sshManager.on(`error:${paneId}`, onError); + + return () => { + if (DEBUG_CLOUD_TERMINAL) { + console.log(`[CloudTerminal Stream] Unsubscribe: ${paneId}`); + } + sshManager.off(`data:${paneId}`, onData); + sshManager.off(`exit:${paneId}`, onExit); + sshManager.off(`error:${paneId}`, onError); + }; + }); + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 2545a0f404b..d52781761ab 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -5,6 +5,7 @@ import { createAuthRouter } from "./auth"; import { createAutoUpdateRouter } from "./auto-update"; import { createCacheRouter } from "./cache"; import { createChangesRouter } from "./changes"; +import { createCloudTerminalRouter } from "./cloud-terminal"; import { createConfigRouter } from "./config"; import { createExternalRouter } from "./external"; import { createHotkeysRouter } from "./hotkeys"; @@ -25,6 +26,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { auth: createAuthRouter(), autoUpdate: createAutoUpdateRouter(), cache: createCacheRouter(), + cloudTerminal: createCloudTerminalRouter(), window: createWindowRouter(getWindow), projects: createProjectsRouter(getWindow), workspaces: createWorkspacesRouter(), diff --git a/apps/desktop/src/main/lib/ssh-terminal/index.ts b/apps/desktop/src/main/lib/ssh-terminal/index.ts new file mode 100644 index 00000000000..0191b16a8e4 --- /dev/null +++ b/apps/desktop/src/main/lib/ssh-terminal/index.ts @@ -0,0 +1,7 @@ +export type { + CreateSSHSessionParams, + SSHCredentials, + SSHSession, + SSHSessionResult, +} from "./ssh-manager"; +export { SSHManager, sshManager } from "./ssh-manager"; diff --git a/apps/desktop/src/main/lib/ssh-terminal/ssh-manager.ts b/apps/desktop/src/main/lib/ssh-terminal/ssh-manager.ts new file mode 100644 index 00000000000..1a98266a38a --- /dev/null +++ b/apps/desktop/src/main/lib/ssh-terminal/ssh-manager.ts @@ -0,0 +1,277 @@ +import { EventEmitter } from "node:events"; +import * as pty from "node-pty"; + +const DEBUG_SSH = process.env.SUPERSET_SSH_DEBUG === "1"; + +export interface SSHCredentials { + host: string; + port: number; + username: string; + token?: string; +} + +export interface SSHSession { + paneId: string; + cloudWorkspaceId: string; + pty: pty.IPty; + isAlive: boolean; + cols: number; + rows: number; + startTime: number; + lastActive: number; +} + +export interface CreateSSHSessionParams { + paneId: string; + cloudWorkspaceId: string; + credentials: SSHCredentials; + cols?: number; + rows?: number; +} + +export interface SSHSessionResult { + paneId: string; + isNew: boolean; +} + +/** + * SSH Terminal Manager - manages SSH connections to cloud workspaces + * Uses node-pty to spawn SSH processes + */ +export class SSHManager extends EventEmitter { + private sessions = new Map(); + + /** + * Create a new SSH session to a cloud workspace + */ + async createSession( + params: CreateSSHSessionParams, + ): Promise { + const { + paneId, + cloudWorkspaceId, + credentials, + cols = 80, + rows = 24, + } = params; + + // 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 { paneId, isNew: false }; + } + + if (DEBUG_SSH) { + console.log("[SSHManager] Creating SSH session:", { + paneId, + cloudWorkspaceId, + host: credentials.host, + port: credentials.port, + }); + } + + // Build SSH command arguments + // For Freestyle: ssh {vmId}:{token}@vm-ssh.freestyle.sh + const sshArgs = this.buildSSHArgs(credentials); + + // Spawn SSH process using node-pty + const shell = process.platform === "win32" ? "ssh.exe" : "ssh"; + const ptyProcess = pty.spawn(shell, sshArgs, { + name: "xterm-256color", + cols, + rows, + cwd: process.env.HOME, + env: { + ...process.env, + TERM: "xterm-256color", + }, + }); + + const session: SSHSession = { + paneId, + cloudWorkspaceId, + pty: ptyProcess, + isAlive: true, + cols, + rows, + startTime: Date.now(), + lastActive: Date.now(), + }; + + // Set up data handler + ptyProcess.onData((data) => { + session.lastActive = Date.now(); + this.emit(`data:${paneId}`, data); + }); + + // Set up exit handler + ptyProcess.onExit(({ exitCode, signal }) => { + if (DEBUG_SSH) { + console.log("[SSHManager] SSH session exited:", { + paneId, + exitCode, + signal, + duration: Date.now() - session.startTime, + }); + } + + session.isAlive = false; + this.emit(`exit:${paneId}`, exitCode, signal); + + // Clean up after delay, but only if no new session replaced it + setTimeout(() => { + const currentSession = this.sessions.get(paneId); + if (currentSession === session) { + this.sessions.delete(paneId); + } + }, 5000); + }); + + this.sessions.set(paneId, session); + + if (DEBUG_SSH) { + console.log("[SSHManager] SSH session created:", paneId); + } + + return { paneId, isNew: true }; + } + + /** + * Build SSH command arguments + */ + private buildSSHArgs(credentials: SSHCredentials): string[] { + const args: string[] = []; + + // Disable strict host key checking for cloud VMs (they're ephemeral) + args.push("-o", "StrictHostKeyChecking=no"); + args.push("-o", "UserKnownHostsFile=/dev/null"); + + // Set connection timeout + args.push("-o", "ConnectTimeout=30"); + + // Keep connection alive + args.push("-o", "ServerAliveInterval=30"); + args.push("-o", "ServerAliveCountMax=3"); + + // Port + if (credentials.port !== 22) { + args.push("-p", String(credentials.port)); + } + + // User@host + // For Freestyle: username is "{vmId}:{token}" + args.push(`${credentials.username}@${credentials.host}`); + + return args; + } + + /** + * Write data to SSH session + */ + write(params: { paneId: string; data: string }): void { + const { paneId, data } = params; + const session = this.sessions.get(paneId); + + if (!session || !session.isAlive) { + throw new Error(`SSH session ${paneId} not found or not alive`); + } + + session.pty.write(data); + session.lastActive = Date.now(); + } + + /** + * Resize SSH terminal + */ + resize(params: { paneId: string; cols: number; rows: number }): void { + const { paneId, cols, rows } = params; + const session = this.sessions.get(paneId); + + if (!session || !session.isAlive) { + console.warn( + `Cannot resize SSH session ${paneId}: not found or not alive`, + ); + return; + } + + try { + session.pty.resize(cols, rows); + session.cols = cols; + session.rows = rows; + session.lastActive = Date.now(); + } catch (error) { + console.error(`[SSHManager] Failed to resize session ${paneId}:`, error); + } + } + + /** + * Kill SSH session + */ + async kill(params: { paneId: string }): Promise { + const { paneId } = params; + const session = this.sessions.get(paneId); + + if (!session) { + console.warn(`Cannot kill SSH session ${paneId}: not found`); + return; + } + + if (session.isAlive) { + session.pty.kill(); + } else { + this.sessions.delete(paneId); + } + } + + /** + * Get session info + */ + 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, + }; + } + + /** + * Kill all sessions for a cloud workspace + */ + async killByCloudWorkspaceId( + cloudWorkspaceId: string, + ): Promise<{ killed: number }> { + const sessionsToKill = Array.from(this.sessions.entries()).filter( + ([, session]) => session.cloudWorkspaceId === cloudWorkspaceId, + ); + + for (const [paneId] of sessionsToKill) { + await this.kill({ paneId }); + } + + return { killed: sessionsToKill.length }; + } + + /** + * Clean up all sessions + */ + async cleanup(): Promise { + for (const [_paneId, session] of this.sessions.entries()) { + if (session.isAlive) { + session.pty.kill(); + } + } + this.sessions.clear(); + this.removeAllListeners(); + } +} + +/** Singleton SSH manager instance */ +export const sshManager = new SSHManager(); diff --git a/apps/desktop/src/renderer/components/NewCloudWorkspaceModal/NewCloudWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewCloudWorkspaceModal/NewCloudWorkspaceModal.tsx new file mode 100644 index 00000000000..bb85f31ccac --- /dev/null +++ b/apps/desktop/src/renderer/components/NewCloudWorkspaceModal/NewCloudWorkspaceModal.tsx @@ -0,0 +1,200 @@ +import type { SelectRepository } from "@superset/db/schema"; +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useEffect, useRef, useState } from "react"; +import { LuCloud, LuExternalLink, LuGithub } from "react-icons/lu"; +import { env } from "renderer/env.renderer"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useCloudWorkspaceMutations } from "renderer/react-query/cloud-workspaces"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { create } from "zustand"; + +interface NewCloudWorkspaceModalStore { + isOpen: boolean; + open: () => void; + close: () => void; +} + +export const useNewCloudWorkspaceModal = create( + (set) => ({ + isOpen: false, + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false }), + }), +); + +export function NewCloudWorkspaceModal() { + const { isOpen, close } = useNewCloudWorkspaceModal(); + const [name, setName] = useState(""); + const [repositoryId, setRepositoryId] = useState(null); + const [branch, setBranch] = useState(""); + const nameInputRef = useRef(null); + + const collections = useCollections(); + const { data: repositories } = useLiveQuery( + (q) => q.from({ repositories: collections.repositories }), + [collections], + ); + + const { createWorkspace, isReady } = useCloudWorkspaceMutations(); + const openUrlMutation = electronTrpc.external.openUrl.useMutation(); + + const hasRepositories = repositories && repositories.length > 0; + + const handleOpenGitHubIntegration = () => { + openUrlMutation.mutate(`${env.NEXT_PUBLIC_WEB_URL}/integrations/github`); + }; + + // Focus name input when modal opens + useEffect(() => { + if (isOpen) { + const timer = setTimeout(() => { + nameInputRef.current?.focus(); + }, 50); + return () => clearTimeout(timer); + } + }, [isOpen]); + + const resetForm = () => { + setName(""); + setRepositoryId(null); + setBranch(""); + }; + + const handleClose = () => { + close(); + resetForm(); + }; + + const handleCreate = async () => { + if (!repositoryId) return; + + createWorkspace.mutate( + { + name: name || "Cloud Workspace", + repositoryId, + branch: branch || undefined, + }, + { + onSuccess: () => { + handleClose(); + }, + }, + ); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ( + e.key === "Enter" && + !e.shiftKey && + repositoryId && + !createWorkspace.isPending + ) { + e.preventDefault(); + handleCreate(); + } + }; + + const canCreate = isReady && repositoryId && !createWorkspace.isPending; + + return ( + !open && handleClose()}> + + + + + New Cloud Workspace + + + +
+
+ + setName(e.target.value)} + /> +
+ +
+ + +
+ +
+ + setBranch(e.target.value)} + /> +

+ Leave empty to use the default branch +

+
+ +
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/NewCloudWorkspaceModal/index.ts b/apps/desktop/src/renderer/components/NewCloudWorkspaceModal/index.ts new file mode 100644 index 00000000000..317d3646fbf --- /dev/null +++ b/apps/desktop/src/renderer/components/NewCloudWorkspaceModal/index.ts @@ -0,0 +1,4 @@ +export { + NewCloudWorkspaceModal, + useNewCloudWorkspaceModal, +} from "./NewCloudWorkspaceModal"; 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..1c46b55d324 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/cloud-workspaces/index.ts @@ -0,0 +1,6 @@ +export { useCloudWorkspaceMutations } from "./useCloudWorkspaceMutations"; +export { + useCloudWorkspace, + useCloudWorkspaces, + useCloudWorkspacesByStatus, +} from "./useCloudWorkspaces"; 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..b0e13a18da5 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/cloud-workspaces/useCloudWorkspaceMutations.ts @@ -0,0 +1,121 @@ +import { toast } from "@superset/ui/sonner"; +import { useState } from "react"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; + +/** + * Hook for cloud workspace mutations. + * Calls the API directly and Electric SQL handles sync. + */ +export function useCloudWorkspaceMutations() { + const [isPending, setIsPending] = useState(false); + + const createWorkspace = { + isPending, + mutate: async ( + params: { + name: string; + repositoryId: string; + branch?: string; + providerType?: "freestyle" | "fly"; + autoStopMinutes?: number; + }, + options?: { + onSuccess?: () => void; + onError?: (error: Error) => void; + }, + ) => { + setIsPending(true); + try { + await apiTrpcClient.cloudWorkspace.create.mutate({ + repositoryId: params.repositoryId, + name: params.name, + branch: params.branch, + providerType: params.providerType ?? "freestyle", + autoStopMinutes: params.autoStopMinutes, + }); + toast.success("Cloud workspace created", { + description: "Provisioning VM...", + }); + options?.onSuccess?.(); + } catch (error) { + const err = + error instanceof Error ? error : new Error("Failed to create"); + toast.error("Failed to create cloud workspace", { + description: err.message, + }); + options?.onError?.(err); + } finally { + setIsPending(false); + } + }, + }; + + const pauseWorkspace = { + mutate: async (workspaceId: string) => { + try { + await apiTrpcClient.cloudWorkspace.pause.mutate({ workspaceId }); + toast.success("Workspace paused"); + } catch (error) { + const err = + error instanceof Error ? error : new Error("Failed to pause"); + toast.error("Failed to pause workspace", { + description: err.message, + }); + } + }, + }; + + const resumeWorkspace = { + mutate: async (workspaceId: string) => { + try { + await apiTrpcClient.cloudWorkspace.resume.mutate({ workspaceId }); + toast.success("Workspace resumed"); + } catch (error) { + const err = + error instanceof Error ? error : new Error("Failed to resume"); + toast.error("Failed to resume workspace", { + description: err.message, + }); + } + }, + }; + + const stopWorkspace = { + mutate: async (workspaceId: string) => { + try { + await apiTrpcClient.cloudWorkspace.stop.mutate({ workspaceId }); + toast.success("Workspace stopped"); + } catch (error) { + const err = + error instanceof Error ? error : new Error("Failed to stop"); + toast.error("Failed to stop workspace", { + description: err.message, + }); + } + }, + }; + + const deleteWorkspace = { + mutate: async (workspaceId: string) => { + try { + await apiTrpcClient.cloudWorkspace.delete.mutate({ workspaceId }); + toast.success("Workspace deleted"); + } catch (error) { + const err = + error instanceof Error ? error : new Error("Failed to delete"); + toast.error("Failed to delete workspace", { + description: err.message, + }); + } + }, + }; + + return { + createWorkspace, + pauseWorkspace, + resumeWorkspace, + stopWorkspace, + deleteWorkspace, + isReady: true, + }; +} 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..d18911363a7 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/cloud-workspaces/useCloudWorkspaces.ts @@ -0,0 +1,84 @@ +import type { SelectCloudWorkspace } from "@superset/db/schema"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useMemo } from "react"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +/** + * Hook to get all cloud workspaces for the current organization. + * Uses Electric SQL for real-time sync via useLiveQuery. + */ +export function useCloudWorkspaces() { + const collections = useCollections(); + + const { data, isLoading } = useLiveQuery( + (q) => q.from({ cloudWorkspaces: collections.cloudWorkspaces }), + [collections], + ); + + const cloudWorkspaces: SelectCloudWorkspace[] = data ?? []; + + return { + cloudWorkspaces, + isLoading, + }; +} + +/** + * Hook to get a single cloud workspace by ID. + */ +export function useCloudWorkspace(workspaceId: string | undefined) { + const collections = useCollections(); + + const { data: cloudWorkspaces, isLoading } = useLiveQuery( + (q) => q.from({ cloudWorkspaces: collections.cloudWorkspaces }), + [collections], + ); + + const cloudWorkspace = useMemo(() => { + if (!cloudWorkspaces || !workspaceId) return null; + return cloudWorkspaces.find((w) => w.id === workspaceId) ?? null; + }, [cloudWorkspaces, workspaceId]); + + return { + cloudWorkspace, + isLoading, + }; +} + +/** + * Hook to get cloud workspaces grouped by status. + */ +export function useCloudWorkspacesByStatus() { + const { cloudWorkspaces, isLoading } = useCloudWorkspaces(); + + const grouped = useMemo(() => { + const running: SelectCloudWorkspace[] = []; + const paused: SelectCloudWorkspace[] = []; + const stopped: SelectCloudWorkspace[] = []; + const other: SelectCloudWorkspace[] = []; + + for (const workspace of cloudWorkspaces) { + switch (workspace.status) { + case "running": + running.push(workspace); + break; + case "paused": + paused.push(workspace); + break; + case "stopped": + stopped.push(workspace); + break; + default: + other.push(workspace); + } + } + + return { running, paused, stopped, other }; + }, [cloudWorkspaces]); + + return { + ...grouped, + all: cloudWorkspaces, + isLoading, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 034c542cff7..22378fed46c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -5,6 +5,7 @@ import { useNavigate, } from "@tanstack/react-router"; import { DndProvider } from "react-dnd"; +import { NewCloudWorkspaceModal } from "renderer/components/NewCloudWorkspaceModal"; import { NewWorkspaceModal } from "renderer/components/NewWorkspaceModal"; import { Paywall } from "renderer/components/Paywall"; import { useUpdateListener } from "renderer/components/UpdateToast"; @@ -75,6 +76,7 @@ function AuthenticatedLayout() { + 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 ff2e1f40db6..ce688c00916 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, SelectInvitation, SelectMember, SelectOrganization, @@ -26,6 +27,7 @@ interface OrgCollections { repositories: Collection; members: Collection; users: Collection; + cloudWorkspaces: Collection; invitations: Collection; } @@ -180,6 +182,48 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); + 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({ + repositoryId: item.repositoryId, + name: item.name, + branch: item.branch ?? undefined, + providerType: item.providerType, + autoStopMinutes: item.autoStopMinutes, + }); + return { txid: result.txid }; + }, + onUpdate: async ({ transaction }) => { + const { original, changes } = transaction.mutations[0]; + const result = await apiClient.cloudWorkspace.update.mutate({ + workspaceId: original.id, + ...changes, + }); + 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 }; + }, + }), + ); + const invitations = createCollection( electricCollectionOptions({ id: `invitations-${organizationId}`, @@ -196,7 +240,7 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - return { tasks, taskStatuses, repositories, members, users, invitations }; + return { tasks, taskStatuses, repositories, members, users, cloudWorkspaces, invitations }; } /** diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspaceSection/CloudWorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspaceSection/CloudWorkspaceListItem.tsx new file mode 100644 index 00000000000..065447201d3 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspaceSection/CloudWorkspaceListItem.tsx @@ -0,0 +1,179 @@ +import type { SelectCloudWorkspace } from "@superset/db/schema"; +import { Button } from "@superset/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { cn } from "@superset/ui/utils"; +import { + LuCloud, + LuEllipsisVertical, + LuPause, + LuPlay, + LuSquare, + LuTerminal, + LuTrash2, +} from "react-icons/lu"; +import { useCloudWorkspaceMutations } from "renderer/react-query/cloud-workspaces"; + +interface CloudWorkspaceListItemProps { + workspace: SelectCloudWorkspace; + isCollapsed?: boolean; + onConnect?: (workspaceId: string) => void; +} + +const statusColors: Record = { + running: "bg-green-500", + paused: "bg-yellow-500", + stopped: "bg-gray-500", + provisioning: "bg-blue-500", + error: "bg-red-500", +}; + +const statusLabels: Record = { + running: "Running", + paused: "Paused", + stopped: "Stopped", + provisioning: "Provisioning...", + error: "Error", +}; + +export function CloudWorkspaceListItem({ + workspace, + isCollapsed = false, + onConnect, +}: CloudWorkspaceListItemProps) { + const { pauseWorkspace, resumeWorkspace, stopWorkspace, deleteWorkspace } = + useCloudWorkspaceMutations(); + + const handleConnect = () => { + onConnect?.(workspace.id); + }; + + const handlePause = () => { + pauseWorkspace.mutate(workspace.id); + }; + + const handleResume = () => { + resumeWorkspace.mutate(workspace.id); + }; + + const handleStop = () => { + stopWorkspace.mutate(workspace.id); + }; + + const handleDelete = () => { + deleteWorkspace.mutate(workspace.id); + }; + + const isRunning = workspace.status === "running"; + const isPaused = workspace.status === "paused"; + const _isStopped = workspace.status === "stopped"; + const canConnect = isRunning; + const canPause = isRunning; + const canResume = isPaused; + const canStop = isRunning || isPaused; + + if (isCollapsed) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ + +
+
+
{workspace.name}
+
+ {statusLabels[workspace.status] ?? workspace.status} + {workspace.branch && ` • ${workspace.branch}`} +
+
+
+ +
+ {canConnect && ( + + )} + + + + + + + {canConnect && ( + + + Connect + + )} + {canPause && ( + + + Pause + + )} + {canResume && ( + + + Resume + + )} + {canStop && ( + + + Stop + + )} + + + + Delete + + + +
+
+ ); +} 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..328903343e8 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspaceSection/CloudWorkspaceSection.tsx @@ -0,0 +1,116 @@ +import { Button } from "@superset/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@superset/ui/collapsible"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useState } from "react"; +import { LuChevronRight, LuCloud, LuPlus } from "react-icons/lu"; +import { useCloudWorkspaces } from "renderer/react-query/cloud-workspaces"; +import { STROKE_WIDTH } from "../constants"; +import { CloudWorkspaceListItem } from "./CloudWorkspaceListItem"; + +interface CloudWorkspaceSectionProps { + isCollapsed?: boolean; + onNewWorkspace?: () => void; + onConnectWorkspace?: (workspaceId: string) => void; +} + +export function CloudWorkspaceSection({ + isCollapsed = false, + onNewWorkspace, + onConnectWorkspace, +}: CloudWorkspaceSectionProps) { + const [isOpen, setIsOpen] = useState(true); + const { cloudWorkspaces, isLoading } = useCloudWorkspaces(); + + if (isCollapsed) { + return ( +
+ + +
+ +
+
+ Cloud Workspaces +
+ + {/* Show collapsed workspace indicators */} + {cloudWorkspaces.map((workspace) => ( + + ))} +
+ ); + } + + return ( + +
+ + + + + Cloud Workspaces + + + + + + New Cloud Workspace + +
+ + + {isLoading ? ( +
+ Loading... +
+ ) : cloudWorkspaces.length === 0 ? ( +
+ No cloud workspaces +
+ ) : ( +
+ {cloudWorkspaces.map((workspace) => ( + + ))} +
+ )} +
+
+ ); +} 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..3e6172d4802 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspaceSection/index.ts @@ -0,0 +1,2 @@ +export { CloudWorkspaceListItem } from "./CloudWorkspaceListItem"; +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 9da785799f7..ce9e3d8f1b1 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,7 @@ import { useMemo } from "react"; +import { useNewCloudWorkspaceModal } from "renderer/components/NewCloudWorkspaceModal"; import { useWorkspaceShortcuts } from "renderer/hooks/useWorkspaceShortcuts"; +import { CloudWorkspaceSection } from "./CloudWorkspaceSection"; import { PortsList } from "./PortsList"; import { ProjectSection } from "./ProjectSection"; import { SidebarDropZone } from "./SidebarDropZone"; @@ -14,6 +16,7 @@ export function WorkspaceSidebar({ isCollapsed = false, }: WorkspaceSidebarProps) { const { groups } = useWorkspaceShortcuts(); + const { open: openNewCloudWorkspaceModal } = useNewCloudWorkspaceModal(); // Calculate shortcut base indices for each project group using cumulative offsets const projectShortcutIndices = useMemo( @@ -28,6 +31,11 @@ export function WorkspaceSidebar({ [groups], ); + const handleConnectCloudWorkspace = (workspaceId: string) => { + // TODO: Open cloud terminal tab for the workspace + console.log("[WorkspaceSidebar] Connect to cloud workspace:", workspaceId); + }; + return ( @@ -56,6 +64,12 @@ export function WorkspaceSidebar({ )} + + {!isCollapsed && } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/CloudTerminal/CloudTerminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/CloudTerminal/CloudTerminal.tsx new file mode 100644 index 00000000000..4d817721f30 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/CloudTerminal/CloudTerminal.tsx @@ -0,0 +1,309 @@ +import { Button } from "@superset/ui/button"; +import { Card } from "@superset/ui/card"; +import type { FitAddon } from "@xterm/addon-fit"; +import type { Terminal as XTerm } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { HiExclamationTriangle } from "react-icons/hi2"; +import { LuCloud, LuPower } from "react-icons/lu"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useCloudWorkspace } from "renderer/react-query/cloud-workspaces"; +import { useTerminalTheme } from "renderer/stores/theme"; + +interface CloudTerminalProps { + paneId: string; + cloudWorkspaceId: string; +} + +/** + * Cloud Terminal component for SSH connections to cloud workspaces. + * Uses the cloudTerminal tRPC router for communication. + */ +export function CloudTerminal({ + paneId, + cloudWorkspaceId, +}: CloudTerminalProps) { + const terminalRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); + const isConnectedRef = useRef(false); // Ref for synchronous callback checks + + const [connectionError, setConnectionError] = useState(null); + const [isConnecting, setIsConnecting] = useState(false); + const [isConnected, setIsConnected] = useState(false); // State for subscription reactivity + const [isExited, setIsExited] = useState(false); + + const terminalTheme = useTerminalTheme(); + const { cloudWorkspace, isLoading } = useCloudWorkspace(cloudWorkspaceId); + + // tRPC mutations + const createSession = electronTrpc.cloudTerminal.createSession.useMutation(); + const writeMutation = electronTrpc.cloudTerminal.write.useMutation(); + const resizeMutation = electronTrpc.cloudTerminal.resize.useMutation(); + + // Refs for mutations to avoid recreating callbacks + const writeRef = useRef(writeMutation.mutate); + writeRef.current = writeMutation.mutate; + const resizeRef = useRef(resizeMutation.mutate); + resizeRef.current = resizeMutation.mutate; + + // Stream subscription - uses isConnected state for reactivity + electronTrpc.cloudTerminal.stream.useSubscription(paneId, { + enabled: isConnected, + onData: (event) => { + const xterm = xtermRef.current; + if (!xterm) return; + + if (event.type === "data") { + xterm.write(event.data); + } else if (event.type === "exit") { + setIsExited(true); + xterm.writeln(`\r\n[SSH session exited with code ${event.exitCode}]`); + } else if (event.type === "error") { + console.error("[CloudTerminal] Error:", event.error); + setConnectionError(event.error); + } + }, + }); + + const connect = useCallback(async () => { + if (!cloudWorkspace || cloudWorkspace.status !== "running") { + setConnectionError("Workspace is not running"); + return; + } + + setIsConnecting(true); + setConnectionError(null); + + try { + // Get SSH credentials from the API + const credentials = + await apiTrpcClient.cloudWorkspace.getSSHCredentials.query({ + workspaceId: cloudWorkspaceId, + }); + + const xterm = xtermRef.current; + if (!xterm) throw new Error("Terminal not initialized"); + + // Create SSH session + await createSession.mutateAsync({ + paneId, + cloudWorkspaceId, + credentials: { + host: credentials.host, + port: credentials.port, + username: credentials.username, + token: credentials.token, + }, + cols: xterm.cols, + rows: xterm.rows, + }); + + isConnectedRef.current = true; + setIsConnected(true); + setIsConnecting(false); + } catch (error) { + console.error("[CloudTerminal] Connection failed:", error); + setConnectionError( + error instanceof Error ? error.message : "Connection failed", + ); + setIsConnecting(false); + } + }, [cloudWorkspace, cloudWorkspaceId, paneId, createSession]); + + // Initialize terminal + useEffect(() => { + const container = terminalRef.current; + if (!container) return; + + let xterm: XTerm | null = null; + let fitAddon: FitAddon | null = null; + + const initTerminal = async () => { + const { Terminal } = await import("@xterm/xterm"); + const { FitAddon } = await import("@xterm/addon-fit"); + + xterm = new Terminal({ + cursorBlink: true, + cursorStyle: "block", + fontFamily: "var(--font-mono), monospace", + fontSize: 13, + lineHeight: 1.2, + theme: terminalTheme ?? undefined, + }); + + fitAddon = new FitAddon(); + xterm.loadAddon(fitAddon); + + xterm.open(container); + fitAddon.fit(); + + xtermRef.current = xterm; + fitAddonRef.current = fitAddon; + + // Handle input + xterm.onData((data) => { + if (!isConnectedRef.current) return; + writeRef.current({ paneId, data }); + }); + + // Handle resize + const resizeObserver = new ResizeObserver(() => { + if (fitAddon) { + fitAddon.fit(); + if (xterm && isConnectedRef.current) { + resizeRef.current({ + paneId, + cols: xterm.cols, + rows: xterm.rows, + }); + } + } + }); + resizeObserver.observe(container); + + // Auto-connect when workspace is ready + if (cloudWorkspace?.status === "running") { + connect(); + } + + return () => { + resizeObserver.disconnect(); + }; + }; + + const cleanupPromise = initTerminal(); + + return () => { + cleanupPromise.then((cleanup) => cleanup?.()); + if (xterm) { + xterm.dispose(); + } + xtermRef.current = null; + fitAddonRef.current = null; + isConnectedRef.current = false; + setIsConnected(false); + }; + // Note: terminalTheme is intentionally NOT in deps - theme updates are handled separately below + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cloudWorkspace?.status, connect, paneId]); + + // Update theme without reinitializing terminal + useEffect(() => { + const xterm = xtermRef.current; + if (xterm && terminalTheme) { + xterm.options.theme = terminalTheme; + } + }, [terminalTheme]); + + const handleRetry = () => { + setConnectionError(null); + setIsExited(false); + connect(); + }; + + const terminalBg = terminalTheme?.background ?? "#1e1e1e"; + + if (isLoading) { + return ( +
+
+ Loading workspace... +
+
+ ); + } + + if (!cloudWorkspace) { + return ( +
+ + +

Workspace not found

+

+ The cloud workspace may have been deleted. +

+
+
+ ); + } + + if (cloudWorkspace.status !== "running") { + return ( +
+ + +

Workspace not running

+

+ Status: {cloudWorkspace.status} +

+

+ Start the workspace to connect. +

+
+
+ ); + } + + return ( +
+ {isConnecting && ( +
+ +
+ +

Connecting...

+

+ Establishing SSH connection +

+
+
+
+ )} + + {connectionError && ( +
+ +
+ +

Connection failed

+

{connectionError}

+
+ +
+
+ )} + + {isExited && ( +
+ +
+ +

Session ended

+
+ +
+
+ )} + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/CloudTerminal/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/CloudTerminal/index.ts new file mode 100644 index 00000000000..d2f90ef8256 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/CloudTerminal/index.ts @@ -0,0 +1 @@ +export { CloudTerminal } from "./CloudTerminal"; diff --git a/bun.lock b/bun.lock index 7438d2a3d1e..aee2eb714dc 100644 --- a/bun.lock +++ b/bun.lock @@ -529,6 +529,7 @@ "@vercel/blob": "^2.0.0", "@vercel/kv": "^3.0.0", "drizzle-orm": "0.45.1", + "freestyle-sandboxes": "^0.1.8", "superjson": "^2.2.5", "zod": "^4.3.5", }, @@ -2905,6 +2906,8 @@ "freeport-async": ["freeport-async@2.0.0", "", {}, "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ=="], + "freestyle-sandboxes": ["freestyle-sandboxes@0.1.8", "", {}, "sha512-Sr2MoKzPUx8mwq6/fIlHseGMS5bYMWtKPH/O+Uo1jLi1IhG6DzLna0F2m3ZcZOI5QlT3YokLwdNliiOpN/IsYA=="], + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "friendly-words": ["friendly-words@1.3.1", "", { "dependencies": { "ava": "^5.3.1", "express": "^4.18.2", "lodash.samplesize": "^4.2.0" } }, "sha512-gLlK15jM/U/oFtYkw4At0cVS0kWst41BRPG4EnhP/L7ZGn8rnOSlLuffIvO/JIK06TYx7abRpNMTzkwpHL+kEA=="], 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..4b611b627ff --- /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 `0012_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/drizzle/0012_add_cloud_workspaces.sql b/packages/db/drizzle/0012_add_cloud_workspaces.sql new file mode 100644 index 00000000000..0789bad9a7d --- /dev/null +++ b/packages/db/drizzle/0012_add_cloud_workspaces.sql @@ -0,0 +1,41 @@ +CREATE TYPE "public"."cloud_client_type" AS ENUM('desktop', 'web');--> statement-breakpoint +CREATE TYPE "public"."cloud_provider_type" AS ENUM('freestyle', 'fly');--> statement-breakpoint +CREATE TYPE "public"."cloud_workspace_status" AS ENUM('provisioning', 'running', 'paused', 'stopped', 'error');--> statement-breakpoint +CREATE TABLE "cloud_workspace_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "client_type" "cloud_client_type" DEFAULT 'desktop' NOT NULL, + "connected_at" timestamp DEFAULT now() NOT NULL, + "last_heartbeat_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "cloud_workspaces" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "repository_id" uuid NOT NULL, + "creator_id" uuid NOT NULL, + "name" text NOT NULL, + "branch" text NOT NULL, + "provider_type" "cloud_provider_type" DEFAULT 'freestyle' NOT NULL, + "provider_vm_id" text, + "status" "cloud_workspace_status" DEFAULT 'provisioning' NOT NULL, + "status_message" text, + "auto_stop_minutes" integer DEFAULT 30 NOT NULL, + "last_active_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "cloud_workspace_sessions" ADD CONSTRAINT "cloud_workspace_sessions_workspace_id_cloud_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."cloud_workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cloud_workspace_sessions" ADD CONSTRAINT "cloud_workspace_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cloud_workspaces" ADD CONSTRAINT "cloud_workspaces_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cloud_workspaces" ADD CONSTRAINT "cloud_workspaces_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "public"."repositories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cloud_workspaces" ADD CONSTRAINT "cloud_workspaces_creator_id_users_id_fk" FOREIGN KEY ("creator_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "cloud_workspace_sessions_workspace_id_idx" ON "cloud_workspace_sessions" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "cloud_workspace_sessions_user_id_idx" ON "cloud_workspace_sessions" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "cloud_workspaces_organization_id_idx" ON "cloud_workspaces" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "cloud_workspaces_repository_id_idx" ON "cloud_workspaces" USING btree ("repository_id");--> statement-breakpoint +CREATE INDEX "cloud_workspaces_creator_id_idx" ON "cloud_workspaces" USING btree ("creator_id");--> statement-breakpoint +CREATE INDEX "cloud_workspaces_status_idx" ON "cloud_workspaces" USING btree ("status");--> statement-breakpoint +CREATE INDEX "cloud_workspaces_provider_vm_id_idx" ON "cloud_workspaces" USING btree ("provider_vm_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0012_snapshot.json b/packages/db/drizzle/meta/0012_snapshot.json new file mode 100644 index 00000000000..f66196c2326 --- /dev/null +++ b/packages/db/drizzle/meta/0012_snapshot.json @@ -0,0 +1,2546 @@ +{ + "id": "83400d38-d3bf-4312-98f9-2f5fc8fd4c0c", + "prevId": "b2d395c3-681e-4e1c-9d86-b08e75cd5b03", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.accounts": { + "name": "accounts", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.invitations": { + "name": "invitations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.members": { + "name": "members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_user_id_idx": { + "name": "members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.organizations": { + "name": "organizations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.sessions": { + "name": "sessions", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_ids": { + "name": "organization_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verifications": { + "name": "verifications", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_workspace_sessions": { + "name": "cloud_workspace_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_type": { + "name": "client_type", + "type": "cloud_client_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'desktop'" + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cloud_workspace_sessions_workspace_id_idx": { + "name": "cloud_workspace_sessions_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloud_workspace_sessions_user_id_idx": { + "name": "cloud_workspace_sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_workspace_sessions_workspace_id_cloud_workspaces_id_fk": { + "name": "cloud_workspace_sessions_workspace_id_cloud_workspaces_id_fk", + "tableFrom": "cloud_workspace_sessions", + "tableTo": "cloud_workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_workspace_sessions_user_id_users_id_fk": { + "name": "cloud_workspace_sessions_user_id_users_id_fk", + "tableFrom": "cloud_workspace_sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_workspaces": { + "name": "cloud_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "cloud_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'freestyle'" + }, + "provider_vm_id": { + "name": "provider_vm_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "cloud_workspace_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'provisioning'" + }, + "status_message": { + "name": "status_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_stop_minutes": { + "name": "auto_stop_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cloud_workspaces_organization_id_idx": { + "name": "cloud_workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloud_workspaces_repository_id_idx": { + "name": "cloud_workspaces_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloud_workspaces_creator_id_idx": { + "name": "cloud_workspaces_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloud_workspaces_status_idx": { + "name": "cloud_workspaces_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloud_workspaces_provider_vm_id_idx": { + "name": "cloud_workspaces_provider_vm_id_idx", + "columns": [ + { + "expression": "provider_vm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_workspaces_organization_id_organizations_id_fk": { + "name": "cloud_workspaces_organization_id_organizations_id_fk", + "tableFrom": "cloud_workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_workspaces_repository_id_repositories_id_fk": { + "name": "cloud_workspaces_repository_id_repositories_id_fk", + "tableFrom": "cloud_workspaces", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_workspaces_creator_id_users_id_fk": { + "name": "cloud_workspaces_creator_id_users_id_fk", + "tableFrom": "cloud_workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended": { + "name": "suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_idx": { + "name": "github_installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_organization_id_organizations_id_fk": { + "name": "github_installations_organization_id_organizations_id_fk", + "tableFrom": "github_installations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_connected_by_user_id_users_id_fk": { + "name": "github_installations_connected_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + }, + "github_installations_org_unique": { + "name": "github_installations_org_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pull_requests": { + "name": "github_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "checks": { + "name": "checks", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pull_requests_repository_id_idx": { + "name": "github_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_state_idx": { + "name": "github_pull_requests_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_head_branch_idx": { + "name": "github_pull_requests_head_branch_idx", + "columns": [ + { + "expression": "head_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pull_requests_repository_id_github_repositories_id_fk": { + "name": "github_pull_requests_repository_id_github_repositories_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "github_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_pull_requests_repo_pr_unique": { + "name": "github_pull_requests_repo_pr_unique", + "nullsNotDistinct": false, + "columns": [ + "repository_id", + "pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repositories_installation_id_idx": { + "name": "github_repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_full_name_idx": { + "name": "github_repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_installations_id_fk": { + "name": "github_repositories_installation_id_github_installations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "github_installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_repositories_repo_id_unique": { + "name": "github_repositories_repo_id_unique", + "nullsNotDistinct": false, + "columns": [ + "repo_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "ingest.webhook_events": { + "name": "webhook_events", + "schema": "ingest", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_events_provider_status_idx": { + "name": "webhook_events_provider_status_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_provider_event_id_idx": { + "name": "webhook_events_provider_event_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_received_at_idx": { + "name": "webhook_events_received_at_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_connections": { + "name": "integration_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "external_org_id": { + "name": "external_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_name": { + "name": "external_org_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_connections_org_idx": { + "name": "integration_connections_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_connections_organization_id_organizations_id_fk": { + "name": "integration_connections_organization_id_organizations_id_fk", + "tableFrom": "integration_connections", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_connected_by_user_id_users_id_fk": { + "name": "integration_connections_connected_by_user_id_users_id_fk", + "tableFrom": "integration_connections", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_connections_unique": { + "name": "integration_connections_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositories_organization_id_idx": { + "name": "repositories_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_slug_idx": { + "name": "repositories_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repositories_organization_id_organizations_id_fk": { + "name": "repositories_organization_id_organizations_id_fk", + "tableFrom": "repositories", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "repositories_org_slug_unique": { + "name": "repositories_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_statuses": { + "name": "task_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "progress_percent": { + "name": "progress_percent", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_statuses_organization_id_idx": { + "name": "task_statuses_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_statuses_type_idx": { + "name": "task_statuses_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_statuses_organization_id_organizations_id_fk": { + "name": "task_statuses_organization_id_organizations_id_fk", + "tableFrom": "task_statuses", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_statuses_org_external_unique": { + "name": "task_statuses_org_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "task_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_repository_id_idx": { + "name": "tasks_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_creator_id_idx": { + "name": "tasks_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_id_idx": { + "name": "tasks_status_id_idx", + "columns": [ + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_provider_idx": { + "name": "tasks_external_provider_idx", + "columns": [ + { + "expression": "external_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_status_id_task_statuses_id_fk": { + "name": "tasks_status_id_task_statuses_id_fk", + "tableFrom": "tasks", + "tableTo": "task_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_repository_id_repositories_id_fk": { + "name": "tasks_repository_id_repositories_id_fk", + "tableFrom": "tasks", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "tasks_external_unique": { + "name": "tasks_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.cloud_client_type": { + "name": "cloud_client_type", + "schema": "public", + "values": [ + "desktop", + "web" + ] + }, + "public.cloud_provider_type": { + "name": "cloud_provider_type", + "schema": "public", + "values": [ + "freestyle", + "fly" + ] + }, + "public.cloud_workspace_status": { + "name": "cloud_workspace_status", + "schema": "public", + "values": [ + "provisioning", + "running", + "paused", + "stopped", + "error" + ] + }, + "public.integration_provider": { + "name": "integration_provider", + "schema": "public", + "values": [ + "linear", + "github" + ] + }, + "public.task_priority": { + "name": "task_priority", + "schema": "public", + "values": [ + "urgent", + "high", + "medium", + "low", + "none" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "backlog", + "todo", + "planning", + "working", + "needs-feedback", + "ready-to-merge", + "completed", + "canceled" + ] + } + }, + "schemas": { + "auth": "auth", + "ingest": "ingest" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 910591975a2..b3d4e76af80 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1768871460342, "tag": "0011_add_github_integration_tables", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1768961713762, + "tag": "0012_add_cloud_workspaces", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/cloud-workspace.ts b/packages/db/src/schema/cloud-workspace.ts new file mode 100644 index 00000000000..97a0550154c --- /dev/null +++ b/packages/db/src/schema/cloud-workspace.ts @@ -0,0 +1,119 @@ +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"; + +// PostgreSQL enums for cloud workspaces +export const cloudWorkspaceStatus = pgEnum( + "cloud_workspace_status", + cloudWorkspaceStatusValues, +); +export const cloudProviderType = pgEnum( + "cloud_provider_type", + cloudProviderTypeValues, +); +export const cloudClientType = pgEnum( + "cloud_client_type", + cloudClientTypeValues, +); + +// Cloud Workspaces table +export const cloudWorkspaces = pgTable( + "cloud_workspaces", + { + id: uuid().primaryKey().defaultRandom(), + + // Ownership + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + repositoryId: uuid("repository_id") + .notNull() + .references(() => repositories.id, { onDelete: "cascade" }), + creatorId: uuid("creator_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + // Workspace details + name: text().notNull(), + branch: text().notNull(), + + // Cloud provider + providerType: cloudProviderType("provider_type") + .notNull() + .default("freestyle"), + providerVmId: text("provider_vm_id"), + + // Status + status: cloudWorkspaceStatus().notNull().default("provisioning"), + statusMessage: text("status_message"), + + // Settings + 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), + index("cloud_workspaces_provider_vm_id_idx").on(table.providerVmId), + ], +); + +export type InsertCloudWorkspace = typeof cloudWorkspaces.$inferInsert; +export type SelectCloudWorkspace = typeof cloudWorkspaces.$inferSelect; + +// Cloud Workspace Sessions table - tracks connected clients +export const cloudWorkspaceSessions = pgTable( + "cloud_workspace_sessions", + { + id: uuid().primaryKey().defaultRandom(), + + // References + workspaceId: uuid("workspace_id") + .notNull() + .references(() => cloudWorkspaces.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + // Client info + clientType: cloudClientType("client_type").notNull().default("desktop"), + + // Timestamps + 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 5bd62de50e8..0e2033ee1f6 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", "github"] 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 cloud workspace 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 0d3a01a8335..6f2457a5cdd 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -1,4 +1,5 @@ export * from "./auth"; +export * from "./cloud-workspace"; export * from "./enums"; export * from "./github"; export * from "./ingest"; diff --git a/packages/db/src/schema/relations.ts b/packages/db/src/schema/relations.ts index 16e18b2daa5..71e3ca493d3 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 { cloudWorkspaceSessions, cloudWorkspaces } from "./cloud-workspace"; import { githubInstallations, githubPullRequests, @@ -28,6 +29,8 @@ export const usersRelations = relations(users, ({ many }) => ({ createdTasks: many(tasks, { relationName: "creator" }), assignedTasks: many(tasks, { relationName: "assignee" }), connectedIntegrations: many(integrationConnections), + createdCloudWorkspaces: many(cloudWorkspaces, { relationName: "creator" }), + cloudWorkspaceSessions: many(cloudWorkspaceSessions), githubInstallations: many(githubInstallations), })); @@ -52,6 +55,7 @@ export const organizationsRelations = relations(organizations, ({ many }) => ({ tasks: many(tasks), taskStatuses: many(taskStatuses), integrations: many(integrationConnections), + cloudWorkspaces: many(cloudWorkspaces), githubInstallations: many(githubInstallations), })); @@ -85,6 +89,7 @@ export const repositoriesRelations = relations( references: [organizations.id], }), tasks: many(tasks), + cloudWorkspaces: many(cloudWorkspaces), }), ); @@ -138,6 +143,40 @@ 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], + relationName: "creator", + }), + 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], + }), + }), +); + // GitHub relations export const githubInstallationsRelations = relations( githubInstallations, diff --git a/packages/local-db/drizzle/0013_add_cloud_workspaces.sql b/packages/local-db/drizzle/0013_add_cloud_workspaces.sql new file mode 100644 index 00000000000..7c0e4830f83 --- /dev/null +++ b/packages/local-db/drizzle/0013_add_cloud_workspaces.sql @@ -0,0 +1,43 @@ +-- Cloud Workspaces table - synced from cloud via Electric SQL +CREATE TABLE `cloud_workspaces` ( + `id` text PRIMARY KEY NOT NULL, + `organization_id` text NOT NULL, + `repository_id` text NOT NULL, + `creator_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, + `auto_stop_minutes` integer NOT NULL, + `last_active_at` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `cloud_workspaces_organization_id_idx` ON `cloud_workspaces` (`organization_id`); +--> statement-breakpoint +CREATE INDEX `cloud_workspaces_repository_id_idx` ON `cloud_workspaces` (`repository_id`); +--> statement-breakpoint +CREATE INDEX `cloud_workspaces_creator_id_idx` ON `cloud_workspaces` (`creator_id`); +--> statement-breakpoint +CREATE INDEX `cloud_workspaces_status_idx` ON `cloud_workspaces` (`status`); +--> statement-breakpoint +-- Cloud Workspace Sessions table - synced from cloud via Electric SQL +CREATE TABLE `cloud_workspace_sessions` ( + `id` text PRIMARY KEY NOT NULL, + `workspace_id` text NOT NULL, + `user_id` text NOT NULL, + `client_type` text NOT NULL, + `connected_at` text NOT NULL, + `last_heartbeat_at` text NOT NULL, + FOREIGN KEY (`workspace_id`) REFERENCES `cloud_workspaces`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `cloud_workspace_sessions_workspace_id_idx` ON `cloud_workspace_sessions` (`workspace_id`); +--> statement-breakpoint +CREATE INDEX `cloud_workspace_sessions_user_id_idx` ON `cloud_workspace_sessions` (`user_id`); diff --git a/packages/local-db/src/schema/relations.ts b/packages/local-db/src/schema/relations.ts index d58548995f4..78ba8034ea2 100644 --- a/packages/local-db/src/schema/relations.ts +++ b/packages/local-db/src/schema/relations.ts @@ -1,5 +1,11 @@ import { relations } from "drizzle-orm"; -import { projects, workspaces, worktrees } from "./schema"; +import { + cloudWorkspaceSessions, + cloudWorkspaces, + projects, + workspaces, + worktrees, +} from "./schema"; export const projectsRelations = relations(projects, ({ many }) => ({ worktrees: many(worktrees), @@ -24,3 +30,21 @@ export const workspacesRelations = relations(workspaces, ({ one }) => ({ references: [worktrees.id], }), })); + +// Cloud workspace relations (synced tables) +export const cloudWorkspacesRelations = relations( + cloudWorkspaces, + ({ many }) => ({ + sessions: many(cloudWorkspaceSessions), + }), +); + +export const cloudWorkspaceSessionsRelations = relations( + cloudWorkspaceSessions, + ({ one }) => ({ + workspace: one(cloudWorkspaces, { + fields: [cloudWorkspaceSessions.workspace_id], + references: [cloudWorkspaces.id], + }), + }), +); diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index 6ffc0c890a0..e425b7d1b08 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -281,3 +281,82 @@ export const tasks = sqliteTable( export type InsertTask = typeof tasks.$inferInsert; export type SelectTask = typeof tasks.$inferSelect; + +// ============================================================================= +// Cloud Workspace types +// ============================================================================= + +export type CloudWorkspaceStatus = + | "provisioning" + | "running" + | "paused" + | "stopped" + | "error"; +export type CloudProviderType = "freestyle" | "fly"; +export type CloudClientType = "desktop" | "web"; + +/** + * Cloud Workspaces table - synced from cloud + * Represents remote VMs accessible from any device + */ +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(), + creator_id: text("creator_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + 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"), + 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(), + }, + (table) => [ + index("cloud_workspaces_organization_id_idx").on(table.organization_id), + index("cloud_workspaces_repository_id_idx").on(table.repository_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; + +/** + * Cloud Workspace Sessions table - synced from cloud + * Tracks connected clients for presence and activity + */ +export const cloudWorkspaceSessions = sqliteTable( + "cloud_workspace_sessions", + { + id: text("id").primaryKey(), + workspace_id: text("workspace_id") + .notNull() + .references(() => cloudWorkspaces.id, { onDelete: "cascade" }), + user_id: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + client_type: text("client_type").notNull().$type(), + connected_at: text("connected_at").notNull(), + last_heartbeat_at: text("last_heartbeat_at").notNull(), + }, + (table) => [ + index("cloud_workspace_sessions_workspace_id_idx").on(table.workspace_id), + index("cloud_workspace_sessions_user_id_idx").on(table.user_id), + ], +); + +export type InsertCloudWorkspaceSession = + typeof cloudWorkspaceSessions.$inferInsert; +export type SelectCloudWorkspaceSession = + typeof cloudWorkspaceSessions.$inferSelect; diff --git a/packages/trpc/package.json b/packages/trpc/package.json index be2cd8867e1..b93330df176 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.8", "superjson": "^2.2.5", "zod": "^4.3.5" }, diff --git a/packages/trpc/src/env.ts b/packages/trpc/src/env.ts index b0f11862257..49f8558952f 100644 --- a/packages/trpc/src/env.ts +++ b/packages/trpc/src/env.ts @@ -17,6 +17,8 @@ export const env = createEnv({ NEXT_PUBLIC_WEB_URL: z.string().url(), KV_REST_API_URL: z.string().url().optional(), KV_REST_API_TOKEN: z.string().optional(), + // Cloud workspace provider + FREESTYLE_API_KEY: z.string().optional(), // GitHub App credentials GH_APP_ID: z.string().min(1), GH_APP_PRIVATE_KEY: z.string().min(1), 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..773f867c2b2 --- /dev/null +++ b/packages/trpc/src/lib/cloud-providers/freestyle-provider.ts @@ -0,0 +1,141 @@ +import type { CloudWorkspaceStatus } from "@superset/db/schema"; +import { Freestyle } from "freestyle-sandboxes"; + +import { env } from "../../env"; +import type { + CloudProviderInterface, + CreateVMParams, + SSHCredentials, + VMStatus, +} from "./types"; + +// Freestyle VM statuses mapped to our CloudWorkspaceStatus +const FREESTYLE_STATUS_MAP: Record = { + running: "running", + suspended: "paused", + stopped: "stopped", + starting: "provisioning", + suspending: "paused", + error: "error", +}; + +function _mapFreestyleStatus(status: string): CloudWorkspaceStatus { + return FREESTYLE_STATUS_MAP[status.toLowerCase()] ?? "error"; +} + +export class FreestyleProvider implements CloudProviderInterface { + readonly type = "freestyle" as const; + private freestyle: Freestyle; + + constructor() { + const apiKey = env.FREESTYLE_API_KEY; + if (!apiKey) { + throw new Error( + "FREESTYLE_API_KEY is required for FreestyleProvider. Set it in your environment variables.", + ); + } + this.freestyle = new Freestyle({ apiKey }); + } + + async createVM(params: CreateVMParams): Promise<{ + vmId: string; + status: CloudWorkspaceStatus; + }> { + console.log("[cloud/freestyle] Creating VM for repo:", params.repoUrl, "branch:", params.branch); + + const { vmId } = await this.freestyle.vms.create({ + gitRepos: [ + { + repo: params.repoUrl, + path: params.workdir ?? "/workspace", + rev: params.branch, // Branch, tag, or commit to checkout + }, + ], + workdir: params.workdir ?? "/workspace", + idleTimeoutSeconds: params.idleTimeoutSeconds ?? 1800, // 30 min default + persistence: { type: "sticky" }, // Persist filesystem across restarts + }); + + console.log("[cloud/freestyle] VM created with id:", vmId); + return { vmId, status: "running" }; + } + + async pauseVM(vmId: string): Promise { + console.log("[cloud/freestyle] Suspending VM:", vmId); + + const vm = this.freestyle.vms.ref({ vmId }); + await vm.suspend(); + + return { status: "paused" }; + } + + async resumeVM(vmId: string): Promise { + console.log("[cloud/freestyle] Resuming VM:", vmId); + + const vm = this.freestyle.vms.ref({ vmId }); + await vm.start(); + + return { status: "running" }; + } + + async stopVM(vmId: string): Promise { + console.log("[cloud/freestyle] Stopping VM:", vmId); + + // Freestyle doesn't have a separate "stop" - we use suspend for now + // which preserves state and allows quick resume + const vm = this.freestyle.vms.ref({ vmId }); + await vm.suspend(); + + return { status: "stopped" }; + } + + async deleteVM(vmId: string): Promise { + console.log("[cloud/freestyle] Deleting VM:", vmId); + + await this.freestyle.vms.delete({ vmId }); + } + + async getVMStatus(vmId: string): Promise { + console.log("[cloud/freestyle] Getting status for VM:", vmId); + + // Get VM info - Freestyle SDK ref doesn't directly expose status + // We need to list and find, or use exec to check if it's running + try { + const vm = this.freestyle.vms.ref({ vmId }); + // Try a simple exec to check if VM is running + await vm.exec("echo ok"); + return { status: "running" }; + } catch (_error) { + // If exec fails, VM might be suspended or stopped + console.log("[cloud/freestyle] VM not running, may be paused:", vmId); + return { status: "paused" }; + } + } + + async getSSHCredentials(vmId: string): Promise { + console.log("[cloud/freestyle] Getting SSH credentials for VM:", vmId); + + // Freestyle SSH access is via token-based authentication + // Format: ssh {vmId}:{token}@vm-ssh.freestyle.sh + // We need to: + // 1. Create/get an identity + // 2. Grant it VM permissions + // 3. Create a token + + // Create an identity for this SSH session + const { identity } = await this.freestyle.identities.create({}); + + // Grant VM permissions to this identity + await identity.permissions.vms.grant({ vmId }); + + // Create an access token + const { token } = await identity.tokens.create(); + + return { + host: "vm-ssh.freestyle.sh", + port: 22, + username: `${vmId}:${token}`, + token, + }; + } +} 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..6f999e13947 --- /dev/null +++ b/packages/trpc/src/lib/cloud-providers/index.ts @@ -0,0 +1,38 @@ +import type { CloudProviderType } from "@superset/db/schema"; + +import { FreestyleProvider } from "./freestyle-provider"; +import type { CloudProviderInterface } from "./types"; + +// Cache provider instances to avoid recreating them +const providerCache = new Map(); + +/** + * Get a cloud provider instance by type. + * Instances are cached for reuse. + */ +export function getCloudProvider( + type: CloudProviderType, +): CloudProviderInterface { + const cached = providerCache.get(type); + if (cached) { + return cached; + } + + let provider: CloudProviderInterface; + + switch (type) { + case "freestyle": + provider = new FreestyleProvider(); + break; + case "fly": + throw new Error("Fly.io provider not yet implemented"); + default: + throw new Error(`Unknown cloud provider: ${type}`); + } + + providerCache.set(type, provider); + return provider; +} + +export { FreestyleProvider } from "./freestyle-provider"; +export * from "./types"; 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..fc9228fd835 --- /dev/null +++ b/packages/trpc/src/lib/cloud-providers/types.ts @@ -0,0 +1,67 @@ +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 the VM (preserves state, faster resume) + */ + pauseVM(vmId: string): Promise; + + /** + * Resume a paused VM + */ + resumeVM(vmId: string): Promise; + + /** + * Stop the VM (graceful shutdown) + */ + stopVM(vmId: string): Promise; + + /** + * Delete the VM permanently + */ + deleteVM(vmId: string): Promise; + + /** + * Get current VM status + */ + getVMStatus(vmId: string): Promise; + + /** + * Get SSH connection credentials for 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..5781e0f6463 --- /dev/null +++ b/packages/trpc/src/router/cloud-workspace/cloud-workspace.ts @@ -0,0 +1,563 @@ +import { db, dbWs } from "@superset/db/client"; +import { + cloudWorkspaceSessions, + cloudWorkspaces, + repositories, +} from "@superset/db/schema"; +import { getCurrentTxid } from "@superset/db/utils"; +import type { TRPCRouterRecord } from "@trpc/server"; +import { TRPCError } from "@trpc/server"; +import { desc, eq } from "drizzle-orm"; + +import { getCloudProvider } from "../../lib/cloud-providers"; +import { protectedProcedure } from "../../trpc"; +import { + cloudWorkspaceIdSchema, + createCloudWorkspaceSchema, + joinSessionSchema, + listCloudWorkspacesSchema, + sessionIdSchema, + updateCloudWorkspaceSchema, +} from "./schema"; + +export const cloudWorkspaceRouter = { + // ============ QUERIES ============ + + /** + * List all cloud workspaces for the user's active organization + */ + list: protectedProcedure + .input(listCloudWorkspacesSchema) + .query(async ({ ctx }) => { + const organizationId = ctx.session.session.activeOrganizationId; + if (!organizationId) { + return []; + } + return db + .select() + .from(cloudWorkspaces) + .where(eq(cloudWorkspaces.organizationId, organizationId)) + .orderBy(desc(cloudWorkspaces.createdAt)); + }), + + /** + * Get a single cloud workspace by ID with relations + */ + get: protectedProcedure + .input(cloudWorkspaceIdSchema) + .query(async ({ input }) => { + const [workspace] = await db + .select() + .from(cloudWorkspaces) + .where(eq(cloudWorkspaces.id, input.workspaceId)) + .limit(1); + + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Cloud workspace not found", + }); + } + + return workspace; + }), + + /** + * Get SSH credentials for connecting to a cloud workspace + */ + getSSHCredentials: protectedProcedure + .input(cloudWorkspaceIdSchema) + .query(async ({ input }) => { + const [workspace] = await db + .select() + .from(cloudWorkspaces) + .where(eq(cloudWorkspaces.id, input.workspaceId)) + .limit(1); + + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Cloud workspace not found", + }); + } + + if (workspace.status !== "running") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Cannot get SSH credentials for workspace in "${workspace.status}" state. Workspace must be running.`, + }); + } + + if (!workspace.providerVmId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Workspace has no VM ID. It may still be provisioning.", + }); + } + + const provider = getCloudProvider(workspace.providerType); + return provider.getSSHCredentials(workspace.providerVmId); + }), + + /** + * Get active sessions for a workspace + */ + getSessions: protectedProcedure + .input(cloudWorkspaceIdSchema) + .query(async ({ input }) => { + return db + .select() + .from(cloudWorkspaceSessions) + .where(eq(cloudWorkspaceSessions.workspaceId, input.workspaceId)); + }), + + // ============ MUTATIONS ============ + + /** + * Create a new cloud workspace + */ + create: protectedProcedure + .input(createCloudWorkspaceSchema) + .mutation(async ({ ctx, input }) => { + // Get organization from session + const organizationId = ctx.session.session.activeOrganizationId; + if (!organizationId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No active organization. Please select an organization first.", + }); + } + + // Get repository info for the repo URL + const [repository] = await db + .select() + .from(repositories) + .where(eq(repositories.id, input.repositoryId)) + .limit(1); + + if (!repository) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Repository not found", + }); + } + + // Use provided branch or repository default branch + const branch = input.branch ?? repository.defaultBranch; + + // Create workspace record in provisioning state + const result = await dbWs.transaction(async (tx) => { + const [workspace] = await tx + .insert(cloudWorkspaces) + .values({ + organizationId, + repositoryId: input.repositoryId, + creatorId: ctx.session.user.id, + name: input.name, + branch, + providerType: input.providerType, + status: "provisioning", + autoStopMinutes: input.autoStopMinutes, + }) + .returning(); + + const txid = await getCurrentTxid(tx); + return { workspace, txid }; + }); + + // Start async provisioning + if (result.workspace) { + provisionWorkspaceAsync({ + workspaceId: result.workspace.id, + repoUrl: repository.repoUrl, + branch, + workspaceName: input.name, + providerType: input.providerType, + autoStopMinutes: input.autoStopMinutes, + }); + } + + return result; + }), + + /** + * Update workspace settings + */ + update: protectedProcedure + .input(updateCloudWorkspaceSchema) + .mutation(async ({ input }) => { + const { workspaceId, ...data } = input; + + const result = await dbWs.transaction(async (tx) => { + const [workspace] = await tx + .update(cloudWorkspaces) + .set(data) + .where(eq(cloudWorkspaces.id, workspaceId)) + .returning(); + + const txid = await getCurrentTxid(tx); + return { workspace, txid }; + }); + + return result; + }), + + /** + * Pause a running workspace + */ + pause: protectedProcedure + .input(cloudWorkspaceIdSchema) + .mutation(async ({ input }) => { + const [workspace] = await db + .select() + .from(cloudWorkspaces) + .where(eq(cloudWorkspaces.id, input.workspaceId)) + .limit(1); + + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Cloud workspace not found", + }); + } + + if (workspace.status !== "running") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Cannot pause workspace in "${workspace.status}" state`, + }); + } + + if (!workspace.providerVmId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Workspace has no VM ID", + }); + } + + const provider = getCloudProvider(workspace.providerType); + await provider.pauseVM(workspace.providerVmId); + + const result = await dbWs.transaction(async (tx) => { + const [updated] = await tx + .update(cloudWorkspaces) + .set({ status: "paused" }) + .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 ({ input }) => { + const [workspace] = await db + .select() + .from(cloudWorkspaces) + .where(eq(cloudWorkspaces.id, input.workspaceId)) + .limit(1); + + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Cloud workspace not found", + }); + } + + if (workspace.status !== "paused" && workspace.status !== "stopped") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Cannot resume workspace in "${workspace.status}" state`, + }); + } + + if (!workspace.providerVmId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Workspace has no VM ID", + }); + } + + const provider = getCloudProvider(workspace.providerType); + await provider.resumeVM(workspace.providerVmId); + + const result = await dbWs.transaction(async (tx) => { + const [updated] = await tx + .update(cloudWorkspaces) + .set({ status: "running", lastActiveAt: new Date() }) + .where(eq(cloudWorkspaces.id, input.workspaceId)) + .returning(); + + const txid = await getCurrentTxid(tx); + return { workspace: updated, txid }; + }); + + return result; + }), + + /** + * Stop a workspace + */ + stop: protectedProcedure + .input(cloudWorkspaceIdSchema) + .mutation(async ({ input }) => { + const [workspace] = await db + .select() + .from(cloudWorkspaces) + .where(eq(cloudWorkspaces.id, input.workspaceId)) + .limit(1); + + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Cloud workspace not found", + }); + } + + if (workspace.status !== "running" && workspace.status !== "paused") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Cannot stop workspace in "${workspace.status}" state`, + }); + } + + if (!workspace.providerVmId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Workspace has no VM ID", + }); + } + + const provider = getCloudProvider(workspace.providerType); + await provider.stopVM(workspace.providerVmId); + + const result = await dbWs.transaction(async (tx) => { + const [updated] = await tx + .update(cloudWorkspaces) + .set({ status: "stopped" }) + .where(eq(cloudWorkspaces.id, input.workspaceId)) + .returning(); + + const txid = await getCurrentTxid(tx); + return { workspace: updated, txid }; + }); + + return result; + }), + + /** + * Delete a workspace permanently + */ + delete: protectedProcedure + .input(cloudWorkspaceIdSchema) + .mutation(async ({ input }) => { + const [workspace] = await db + .select() + .from(cloudWorkspaces) + .where(eq(cloudWorkspaces.id, input.workspaceId)) + .limit(1); + + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Cloud workspace not found", + }); + } + + // 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] Failed to delete VM from provider:", + error, + ); + // Continue with database 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; + }), + + // ============ SESSION MANAGEMENT ============ + + /** + * Join a workspace session (track connected clients) + */ + join: protectedProcedure + .input(joinSessionSchema) + .mutation(async ({ ctx, input }) => { + const [workspace] = await db + .select() + .from(cloudWorkspaces) + .where(eq(cloudWorkspaces.id, input.workspaceId)) + .limit(1); + + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Cloud workspace not found", + }); + } + + // Auto-resume if workspace is paused + if (workspace.status === "paused" && workspace.providerVmId) { + const provider = getCloudProvider(workspace.providerType); + await provider.resumeVM(workspace.providerVmId); + + await dbWs + .update(cloudWorkspaces) + .set({ status: "running", lastActiveAt: new Date() }) + .where(eq(cloudWorkspaces.id, input.workspaceId)); + } + + const result = await dbWs.transaction(async (tx) => { + const [session] = await tx + .insert(cloudWorkspaceSessions) + .values({ + workspaceId: input.workspaceId, + userId: ctx.session.user.id, + clientType: input.clientType, + }) + .returning(); + + // Update workspace last active + await tx + .update(cloudWorkspaces) + .set({ lastActiveAt: new Date() }) + .where(eq(cloudWorkspaces.id, input.workspaceId)); + + const txid = await getCurrentTxid(tx); + return { session, txid }; + }); + + return result; + }), + + /** + * Leave a workspace session + */ + leave: protectedProcedure + .input(sessionIdSchema) + .mutation(async ({ input }) => { + const result = await dbWs.transaction(async (tx) => { + await tx + .delete(cloudWorkspaceSessions) + .where(eq(cloudWorkspaceSessions.id, input.sessionId)); + + const txid = await getCurrentTxid(tx); + return { txid }; + }); + + return result; + }), + + /** + * Send heartbeat to keep session alive + */ + heartbeat: protectedProcedure + .input(sessionIdSchema) + .mutation(async ({ input }) => { + const result = await dbWs.transaction(async (tx) => { + const [session] = await tx + .update(cloudWorkspaceSessions) + .set({ lastHeartbeatAt: new Date() }) + .where(eq(cloudWorkspaceSessions.id, input.sessionId)) + .returning(); + + if (session) { + // Also update workspace last active + await tx + .update(cloudWorkspaces) + .set({ lastActiveAt: new Date() }) + .where(eq(cloudWorkspaces.id, session.workspaceId)); + } + + const txid = await getCurrentTxid(tx); + return { session, txid }; + }); + + return result; + }), +} satisfies TRPCRouterRecord; + +// ============ ASYNC HELPERS ============ + +/** + * Provision a workspace asynchronously + * This runs after the workspace record is created + */ +async function provisionWorkspaceAsync({ + workspaceId, + repoUrl, + branch, + workspaceName, + providerType, + autoStopMinutes, +}: { + workspaceId: string; + repoUrl: string; + branch: string; + workspaceName: string; + providerType: "freestyle" | "fly"; + autoStopMinutes: number; +}) { + console.log( + "[cloud-workspace] Starting async provisioning for:", + workspaceId, + ); + + try { + const provider = getCloudProvider(providerType); + const { vmId, status } = await provider.createVM({ + repoUrl, + branch, + workspaceName, + idleTimeoutSeconds: autoStopMinutes * 60, + }); + + console.log("[cloud-workspace] VM created:", vmId, "status:", status); + + // Update workspace with VM ID and running status + await dbWs + .update(cloudWorkspaces) + .set({ + providerVmId: vmId, + status: "running", + lastActiveAt: new Date(), + }) + .where(eq(cloudWorkspaces.id, workspaceId)); + + console.log("[cloud-workspace] Provisioning complete for:", workspaceId); + } catch (error) { + console.error("[cloud-workspace] Provisioning failed:", error); + + // Update workspace with error status + await dbWs + .update(cloudWorkspaces) + .set({ + status: "error", + statusMessage: + error instanceof Error ? error.message : "Provisioning failed", + }) + .where(eq(cloudWorkspaces.id, workspaceId)); + } +} 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..18777fe7abd --- /dev/null +++ b/packages/trpc/src/router/cloud-workspace/schema.ts @@ -0,0 +1,40 @@ +import { + cloudClientTypeValues, + cloudProviderTypeValues, +} from "@superset/db/schema"; +import { z } from "zod"; + +// Create a new cloud workspace +export const createCloudWorkspaceSchema = z.object({ + repositoryId: z.string().uuid(), + name: z.string().min(1).max(100), + branch: z.string().min(1).optional(), // Optional - uses repo default branch if not provided + providerType: z.enum(cloudProviderTypeValues).default("freestyle"), + autoStopMinutes: z.number().int().min(5).max(480).default(30), // 5 min to 8 hours +}); + +// Workspace ID parameter +export const cloudWorkspaceIdSchema = z.object({ + workspaceId: z.string().uuid(), +}); + +// List workspaces for an organization (uses active organization from session) +export const listCloudWorkspacesSchema = z.object({}); + +// Join a workspace session +export const joinSessionSchema = z.object({ + workspaceId: z.string().uuid(), + clientType: z.enum(cloudClientTypeValues).default("desktop"), +}); + +// Session ID parameter +export const sessionIdSchema = z.object({ + sessionId: z.string().uuid(), +}); + +// Update workspace settings +export const updateCloudWorkspaceSchema = z.object({ + workspaceId: z.string().uuid(), + name: z.string().min(1).max(100).optional(), + autoStopMinutes: z.number().int().min(5).max(480).optional(), +});