diff --git a/apps/mobile/hooks/useCloudWorkspaceActions/index.ts b/apps/mobile/hooks/useCloudWorkspaceActions/index.ts new file mode 100644 index 00000000000..4bf77de9668 --- /dev/null +++ b/apps/mobile/hooks/useCloudWorkspaceActions/index.ts @@ -0,0 +1 @@ +export { useCloudWorkspaceActions } from "./useCloudWorkspaceActions"; diff --git a/apps/mobile/hooks/useCloudWorkspaceActions/useCloudWorkspaceActions.ts b/apps/mobile/hooks/useCloudWorkspaceActions/useCloudWorkspaceActions.ts new file mode 100644 index 00000000000..d8a5903ddfa --- /dev/null +++ b/apps/mobile/hooks/useCloudWorkspaceActions/useCloudWorkspaceActions.ts @@ -0,0 +1,41 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { clearSandboxAccess } from "@/lib/sandbox-access"; +import { apiClient } from "@/lib/trpc/client"; + +/** + * Rename and delete for cloud workspaces go to the API, not the sandbox: the + * cloud row owns the name (the sandbox's copy is scratch), and deleting + * through the sandbox would remove the row inside it and leave the sandbox + * running — and billing — with the cloud row still listing it. + */ +export function useCloudWorkspaceActions() { + const queryClient = useQueryClient(); + + const invalidate = useCallback( + () => + queryClient.invalidateQueries({ + queryKey: ["cloud", "cloudWorkspace", "list"], + }), + [queryClient], + ); + + const rename = useCallback( + async (id: string, name: string) => { + await apiClient.cloudWorkspace.rename.mutate({ id, name }); + await invalidate(); + }, + [invalidate], + ); + + const remove = useCallback( + async (id: string) => { + await apiClient.cloudWorkspace.delete.mutate({ id }); + clearSandboxAccess(id); + await invalidate(); + }, + [invalidate], + ); + + return { rename, remove }; +} diff --git a/apps/mobile/hooks/useCloudWorkspaceItems/index.ts b/apps/mobile/hooks/useCloudWorkspaceItems/index.ts new file mode 100644 index 00000000000..8d4e2642ccc --- /dev/null +++ b/apps/mobile/hooks/useCloudWorkspaceItems/index.ts @@ -0,0 +1,7 @@ +export { + type CloudWorkspaceItem, + type CloudWorkspaceItemsValue, + type CloudWorkspaceStatus, + sandboxWorkspacesQuery, + useCloudWorkspaceItems, +} from "./useCloudWorkspaceItems"; diff --git a/apps/mobile/hooks/useCloudWorkspaceItems/useCloudWorkspaceItems.ts b/apps/mobile/hooks/useCloudWorkspaceItems/useCloudWorkspaceItems.ts new file mode 100644 index 00000000000..b02c66bfc32 --- /dev/null +++ b/apps/mobile/hooks/useCloudWorkspaceItems/useCloudWorkspaceItems.ts @@ -0,0 +1,178 @@ +import { useQueries, useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { + type CloudWorkspaceRow, + useCloudWorkspaces, +} from "@/hooks/useCloudWorkspaces"; +import { + getHostWorkspacesQueryKey, + type HostWorkspaceItem, + type HostWorkspaceRow, + type HostWorkspacesCacheOps, +} from "@/hooks/useHostWorkspaces"; +import { type SandboxTarget, useSandboxAccess } from "@/hooks/useSandboxAccess"; +import { getHostServiceClientByUrl } from "@/lib/host-service/client"; +import { getSandboxAccess } from "@/lib/sandbox-access"; + +const SERVED_REFETCH_INTERVAL_MS = 30_000; + +export type CloudWorkspaceStatus = CloudWorkspaceRow["status"]; + +export interface CloudWorkspaceItem extends HostWorkspaceItem { + cloud: { status: CloudWorkspaceStatus }; +} + +/** + * The row a sandbox serves for its own workspace, restated under the cloud + * workspace's id: the sandbox reports the machine id of the container it + * happens to run in, which addresses nothing from here. + */ +export function sandboxWorkspacesQuery(target: SandboxTarget) { + return { + queryKey: getHostWorkspacesQueryKey(target.workspaceId, target.url), + refetchInterval: SERVED_REFETCH_INTERVAL_MS, + retry: 1, + networkMode: "always" as const, + queryFn: async (): Promise => { + const rows = await getHostServiceClientByUrl( + target.url, + ).workspace.list.query(); + return rows.map((row) => ({ ...row, hostId: target.workspaceId })); + }, + }; +} + +/** + * A cloud row rendered as a list item before its sandbox serves anything — + * while it provisions, after it failed, or in the beat between `ready` and + * host-service answering. The cloud row is the workspace's identity (it is + * what created, named and lists it); the sandbox's own row only adds live git + * state once it exists. + * + * Two fields are invented while the sandbox isn't serving, and both are load + * bearing only in what they prevent: `worktreeExists: true` keeps the list + * from filtering the row as a stale shell, and `worktreePath: ""` satisfies + * the shape — nothing reads a path off a list row (the workspace screen's + * attachment target uses the served row, which has the real one). `type` is + * not invented: the sandbox self-seeds its workspace as `main`. Anything new + * that reads more than that off a home-list row has to decide what an + * unserved sandbox should answer. + */ +function itemFromCloudRow( + cloud: CloudWorkspaceRow, + served: HostWorkspaceRow | undefined, + hostReachable: boolean, +): CloudWorkspaceItem { + return { + id: cloud.id, + organizationId: cloud.organizationId, + projectId: cloud.projectId, + hostId: cloud.id, + name: cloud.name, + branch: served?.branch ?? cloud.branch, + type: "main", + createdByUserId: cloud.createdByUserId ?? null, + taskId: null, + createdAt: cloud.createdAt, + updatedAt: served?.updatedAt ?? cloud.updatedAt, + worktreePath: served?.worktreePath ?? "", + worktreeExists: served ? served.worktreeExists : true, + projectName: served?.projectName ?? null, + archivedAt: null, + archiveReason: null, + hostReachable, + cloud: { status: cloud.status }, + }; +} + +export interface CloudWorkspaceItemsValue { + items: CloudWorkspaceItem[]; + targets: SandboxTarget[]; + cache: HostWorkspacesCacheOps; + /** True once the cloud list answered and every ready sandbox was addressed. */ + isReady: boolean; +} + +/** + * Cloud workspaces as home-list rows: the cloud list for identity and status, + * plus each ready sandbox's own `workspace.list` for live branch and worktree + * state. Query keys match `useHostWorkspaces`, so opening a row resolves + * straight from cache. + */ +export function useCloudWorkspaceItems(): CloudWorkspaceItemsValue { + const queryClient = useQueryClient(); + const { workspaces: cloudRows, isReady: listReady } = useCloudWorkspaces(); + const { targets, isReady: accessReady } = useSandboxAccess(cloudRows); + + const served = useQueries({ + queries: targets.map((target) => sandboxWorkspacesQuery(target)), + }); + + const items = useMemo(() => { + const servedById = new Map< + string, + { row: HostWorkspaceRow | undefined; reachable: boolean } + >(); + targets.forEach((target, index) => { + const query = served[index]; + servedById.set(target.workspaceId, { + row: query?.data?.find((row) => row.id === target.workspaceId), + reachable: Boolean(query?.data) && !query?.isError, + }); + }); + return cloudRows.map((cloud) => { + const entry = servedById.get(cloud.id); + return itemFromCloudRow(cloud, entry?.row, entry?.reachable ?? false); + }); + }, [cloudRows, targets, served]); + + const cache = useMemo(() => { + const keyFor = (hostId: string) => { + const access = getSandboxAccess(hostId); + return access ? getHostWorkspacesQueryKey(hostId, access.url) : null; + }; + return { + resolveHostUrl: (hostId) => getSandboxAccess(hostId)?.url ?? null, + upsertWorkspace: (row) => { + const key = keyFor(row.hostId); + if (!key) return; + queryClient.setQueryData( + key, + (rows) => { + if (!rows) return [row]; + const exists = rows.some((existing) => existing.id === row.id); + return exists + ? rows.map((existing) => + existing.id === row.id ? { ...existing, ...row } : existing, + ) + : [...rows, row]; + }, + ); + }, + removeWorkspace: (hostId, workspaceId) => { + const key = keyFor(hostId); + if (!key) return; + queryClient.setQueryData(key, (rows) => + rows?.filter((row) => row.id !== workspaceId), + ); + }, + invalidateHost: (hostId) => { + const key = keyFor(hostId); + if (key) void queryClient.invalidateQueries({ queryKey: key }); + }, + refetchHost: async (hostId) => { + const key = keyFor(hostId); + if (!key) return undefined; + await queryClient.refetchQueries({ queryKey: key }); + return queryClient.getQueryData(key); + }, + }; + }, [queryClient]); + + return { + items, + targets, + cache, + isReady: listReady && accessReady, + }; +} diff --git a/apps/mobile/hooks/useCloudWorkspaces/index.ts b/apps/mobile/hooks/useCloudWorkspaces/index.ts new file mode 100644 index 00000000000..d58a80cd69a --- /dev/null +++ b/apps/mobile/hooks/useCloudWorkspaces/index.ts @@ -0,0 +1,6 @@ +export { + type CloudWorkspaceRow, + type CloudWorkspacesValue, + getCloudWorkspacesQueryKey, + useCloudWorkspaces, +} from "./useCloudWorkspaces"; diff --git a/apps/mobile/hooks/useCloudWorkspaces/useCloudWorkspaces.ts b/apps/mobile/hooks/useCloudWorkspaces/useCloudWorkspaces.ts new file mode 100644 index 00000000000..785c901fb97 --- /dev/null +++ b/apps/mobile/hooks/useCloudWorkspaces/useCloudWorkspaces.ts @@ -0,0 +1,101 @@ +import { FEATURE_FLAGS } from "@superset/shared/constants"; +import type { RouterOutputs } from "@superset/trpc"; +import { useQuery } from "@tanstack/react-query"; +import { TRPCClientError } from "@trpc/client"; +import { useFeatureFlag } from "posthog-react-native"; +import { useEffect } from "react"; +import { useSession } from "@/lib/auth/client"; +import { pruneSandboxAccess } from "@/lib/sandbox-access"; +import { apiClient } from "@/lib/trpc/client"; + +export type CloudWorkspaceRow = RouterOutputs["cloudWorkspace"]["list"][number]; + +/** + * Someone is watching a provisioning row, so poll like it: a warm sandbox is + * up in about a second, and this poll is the longest part of the wait between + * pressing create and the workspace opening. + */ +const PROVISIONING_POLL_MS = 1_000; +const IDLE_POLL_MS = 30_000; + +const NO_ROWS: CloudWorkspaceRow[] = []; + +export function getCloudWorkspacesQueryKey(organizationId: string | null) { + return ["cloud", "cloudWorkspace", "list", organizationId] as const; +} + +export interface CloudWorkspacesValue { + workspaces: CloudWorkspaceRow[]; + organizationId: string | null; + /** True once the list answered (or the feature is off for this user). */ + isReady: boolean; +} + +/** + * Every cloud workspace in the active organization, including the ones still + * provisioning and the ones that failed: the row exists from the moment create + * returns, and both the home list and the workspace screen render it long + * before a sandbox is behind it. + * + * Gated twice, like desktop: the PostHog flag decides whether to ask, and the + * API refuses non-internal accounts — a FORBIDDEN answer is "no cloud + * workspaces", not an error to show. + */ +export function useCloudWorkspaces(): CloudWorkspacesValue { + const enabledByFlag = Boolean(useFeatureFlag(FEATURE_FLAGS.CLOUD_WORKSPACES)); + const { data: session } = useSession(); + const organizationId = session?.session?.activeOrganizationId ?? null; + + const query = useQuery({ + queryKey: getCloudWorkspacesQueryKey(organizationId), + enabled: enabledByFlag && organizationId !== null, + networkMode: "always" as const, + retry: (count, error) => + !(error instanceof TRPCClientError && error.data?.code === "FORBIDDEN") && + count < 2, + refetchInterval: (current) => + current.state.data?.some((row) => row.status === "provisioning") + ? PROVISIONING_POLL_MS + : IDLE_POLL_MS, + queryFn: async (): Promise => { + if (!organizationId) return NO_ROWS; + try { + return await apiClient.cloudWorkspace.list.query({ organizationId }); + } catch (error) { + if ( + error instanceof TRPCClientError && + error.data?.code === "FORBIDDEN" + ) { + return NO_ROWS; + } + throw error; + } + }, + }); + + // Whatever the list stops naming loses its credentials: an org switch or a + // FORBIDDEN answer lands here as a new (possibly empty) list and clears the + // grants the previous one earned. Losing the scope entirely — sign-out, + // flag off — disables the query, so that case prunes explicitly rather + // than waiting for data that will never come. + const rows = query.data; + const hasScope = enabledByFlag && organizationId !== null; + useEffect(() => { + if (!hasScope) { + pruneSandboxAccess(new Set()); + return; + } + if (!rows) return; + pruneSandboxAccess(new Set(rows.map((row) => row.id))); + }, [hasScope, rows]); + + return { + workspaces: query.data ?? NO_ROWS, + organizationId, + isReady: + !enabledByFlag || + organizationId === null || + query.isSuccess || + query.isError, + }; +} diff --git a/apps/mobile/hooks/useHostProjects/useHostProjects.ts b/apps/mobile/hooks/useHostProjects/useHostProjects.ts index 6d17715f0d1..6a5e15cfa0f 100644 --- a/apps/mobile/hooks/useHostProjects/useHostProjects.ts +++ b/apps/mobile/hooks/useHostProjects/useHostProjects.ts @@ -2,9 +2,9 @@ import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import type { WorkspacesHost } from "@/hooks/useHostWorkspaces"; import { - buildRelayHostUrl, getHostServiceClientByUrl, type HostProjectRow, + hostServiceUrl, } from "@/lib/host-service/client"; export interface HostProjectItem { @@ -54,7 +54,7 @@ export function useHostProjects( host: WorkspacesHost | null, ): UseHostProjectsResult { const hostUrl = host - ? buildRelayHostUrl(host.organizationId, host.machineId) + ? hostServiceUrl(host.organizationId, host.machineId) : null; const query = useQuery({ diff --git a/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts b/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts index 7d2f6f0d404..93094fd872f 100644 --- a/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts +++ b/apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts @@ -1,9 +1,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo } from "react"; import { - buildRelayHostUrl, getHostServiceClientByUrl, type HostWorkspaceRow, + hostServiceUrl, } from "@/lib/host-service/client"; export type { HostWorkspaceRow } from "@/lib/host-service/client"; @@ -62,7 +62,7 @@ export function useHostWorkspaces( const queryClient = useQueryClient(); const hostUrl = host?.isOnline - ? buildRelayHostUrl(host.organizationId, host.machineId) + ? hostServiceUrl(host.organizationId, host.machineId) : null; const machineId = host?.machineId ?? null; const queryKey = getHostWorkspacesQueryKey(machineId, hostUrl); diff --git a/apps/mobile/hooks/useSandboxAccess/index.ts b/apps/mobile/hooks/useSandboxAccess/index.ts new file mode 100644 index 00000000000..3a12a045503 --- /dev/null +++ b/apps/mobile/hooks/useSandboxAccess/index.ts @@ -0,0 +1,5 @@ +export { + type SandboxAccessValue, + type SandboxTarget, + useSandboxAccess, +} from "./useSandboxAccess"; diff --git a/apps/mobile/hooks/useSandboxAccess/useSandboxAccess.ts b/apps/mobile/hooks/useSandboxAccess/useSandboxAccess.ts new file mode 100644 index 00000000000..23450ecaaf0 --- /dev/null +++ b/apps/mobile/hooks/useSandboxAccess/useSandboxAccess.ts @@ -0,0 +1,102 @@ +import { useQueries, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo } from "react"; +import { AppState } from "react-native"; +import type { CloudWorkspaceRow } from "@/hooks/useCloudWorkspaces"; +import { ensureSandboxAccess } from "@/lib/sandbox-access"; + +/** Re-mint with time to spare; the provider's token is short-lived. */ +const REFRESH_AT = 0.8; +const RETRY_MS = 30_000; + +const SANDBOX_ACCESS_QUERY_KEY = ["cloud", "sandbox-access"] as const; + +export interface SandboxTarget { + /** The cloud workspace's id, which is also its host id. */ + workspaceId: string; + organizationId: string; + url: string; +} + +export interface SandboxAccessValue { + targets: SandboxTarget[]; + /** False until every ready cloud workspace has been addressed once. */ + isReady: boolean; +} + +/** + * Keeps a live address for every ready cloud workspace. + * + * A sandbox has no host row and no stable URL — it is reachable only through + * a token this brokers, and that token expires. Minting for all of them + * (rather than only the open one) is what lets the rest of the app treat a + * sandbox as just another host: workspace rows, terminals, diff stats are all + * keyed by host id, so once a sandbox has an address they light up with no + * cloud-specific code. + * + * iOS freezes JS timers in the background, so an interval alone would come + * back to an expired token after a long sleep; returning to the foreground + * re-checks every grant, and the dial path re-mints on its own if stale. + */ +export function useSandboxAccess( + cloudWorkspaces: CloudWorkspaceRow[], +): SandboxAccessValue { + const queryClient = useQueryClient(); + + // Only a `ready` row has a sandbox to address: `access` refuses anything + // else, and a provisioning workspace asking for a token every few seconds + // would be a retry loop against a guaranteed rejection. + const ready = useMemo( + () => cloudWorkspaces.filter((row) => row.status === "ready"), + [cloudWorkspaces], + ); + + const results = useQueries({ + queries: ready.map((row) => ({ + queryKey: [...SANDBOX_ACCESS_QUERY_KEY, row.id] as const, + networkMode: "always" as const, + queryFn: async () => { + const access = await ensureSandboxAccess(row.id); + return { url: access.url, expiresAt: access.expiresAt }; + }, + // No refetchIntervalInBackground: that flag is about browser window + // focus, which React Query never sees here — iOS freezes the timers + // wholesale, and the AppState listener below is the resume path. + refetchInterval: (query: { + state: { data?: { expiresAt: number } }; + }): number => { + const expiresAt = query.state.data?.expiresAt; + if (!expiresAt) return RETRY_MS; + return Math.max(RETRY_MS, (expiresAt - Date.now()) * REFRESH_AT); + }, + })), + }); + + useEffect(() => { + const subscription = AppState.addEventListener("change", (state) => { + if (state !== "active") return; + // ensureSandboxAccess is a no-op for a grant that is still fresh, so + // this only costs a mint for the ones that lapsed while asleep. + void queryClient.invalidateQueries({ + queryKey: SANDBOX_ACCESS_QUERY_KEY, + }); + }); + return () => subscription.remove(); + }, [queryClient]); + + return useMemo(() => { + const targets: SandboxTarget[] = []; + ready.forEach((row, index) => { + const url = results[index]?.data?.url; + if (!url) return; + targets.push({ + workspaceId: row.id, + organizationId: row.organizationId, + url, + }); + }); + return { + targets, + isReady: results.every((result) => result.isFetched), + }; + }, [ready, results]); +} diff --git a/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts b/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts index b382cbb2a3d..daa99b13138 100644 --- a/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts +++ b/apps/mobile/hooks/useWorkspaceHost/useWorkspaceHost.ts @@ -1,19 +1,30 @@ import { useQueries } from "@tanstack/react-query"; import { useMemo } from "react"; +import { sandboxWorkspacesQuery } from "@/hooks/useCloudWorkspaceItems"; +import { + type CloudWorkspaceRow, + useCloudWorkspaces, +} from "@/hooks/useCloudWorkspaces"; import { useHostsPresence } from "@/hooks/useHostsPresence"; import { getHostWorkspacesQueryKey, type HostWorkspaceRow, } from "@/hooks/useHostWorkspaces"; import { NO_HOSTS, type OrgHost, useOrgHostsQuery } from "@/hooks/useOrgHosts"; +import { useSandboxAccess } from "@/hooks/useSandboxAccess"; import { - buildRelayHostUrl, getHostServiceClientByUrl, + hostServiceUrl, } from "@/lib/host-service/client"; export interface WorkspaceHostResult { workspace: HostWorkspaceRow | null; host: OrgHost | null; + /** + * The cloud row when the id names a cloud workspace — present from the + * moment it is created, long before a sandbox serves it. Null otherwise. + */ + cloud: CloudWorkspaceRow | null; /** True while no host has answered yet. */ isResolving: boolean; } @@ -22,6 +33,10 @@ export interface WorkspaceHostResult { * Locate a workspace's row (and owning host) by asking each online host. * Query keys match useHostWorkspaces, so navigating from the list resolves * straight from cache. + * + * A cloud workspace has no host row: its sandbox is its own host, keyed by + * the workspace's id and addressed through a brokered token, so it is looked + * up in the cloud list first and asked directly. */ export function useWorkspaceHost( workspaceId: string | null, @@ -30,34 +45,76 @@ export function useWorkspaceHost( const hosts = hostsQuery.data ?? NO_HOSTS; const presence = useHostsPresence(hosts); + const { workspaces: cloudRows, isReady: cloudReady } = useCloudWorkspaces(); + const cloud = useMemo( + () => cloudRows.find((row) => row.id === workspaceId) ?? null, + [cloudRows, workspaceId], + ); + const cloudTargets = useMemo(() => (cloud ? [cloud] : []), [cloud]); + const { targets: sandboxes, isReady: sandboxReady } = + useSandboxAccess(cloudTargets); + const sandbox = sandboxes[0] ?? null; + const targets = useMemo( () => - hosts - .map((host) => ({ - ...host, - isOnline: presence?.get(host.machineId) ?? host.isOnline, - })) - .filter((host) => host.isOnline) - .map((host) => ({ - host, - hostUrl: buildRelayHostUrl(host.organizationId, host.machineId), - })), - [hosts, presence], + cloud + ? [] + : hosts + .map((host) => ({ + ...host, + isOnline: presence?.get(host.machineId) ?? host.isOnline, + })) + .filter((host) => host.isOnline) + .map((host) => ({ + host, + hostUrl: hostServiceUrl(host.organizationId, host.machineId), + })), + [cloud, hosts, presence], ); const queries = useQueries({ - queries: targets.map(({ host, hostUrl }) => ({ - queryKey: getHostWorkspacesQueryKey(host.machineId, hostUrl), - enabled: workspaceId !== null, - staleTime: 30_000, - retry: 1, - networkMode: "always" as const, - queryFn: async (): Promise => - getHostServiceClientByUrl(hostUrl).workspace.list.query(), - })), + queries: [ + ...(sandbox ? [sandboxWorkspacesQuery(sandbox)] : []), + ...targets.map(({ host, hostUrl }) => ({ + queryKey: getHostWorkspacesQueryKey(host.machineId, hostUrl), + enabled: workspaceId !== null, + staleTime: 30_000, + retry: 1, + networkMode: "always" as const, + queryFn: async (): Promise => + getHostServiceClientByUrl(hostUrl).workspace.list.query(), + })), + ], }); return useMemo(() => { + if (cloud) { + const served = sandbox ? queries[0] : undefined; + const servedRow = + served?.data?.find((row) => row.id === workspaceId) ?? null; + // The cloud row owns the name — it is what created, named and lists + // the workspace; the sandbox's own row is scratch that a rename + // never reaches. Live git state still comes from the sandbox. + const workspace = servedRow ? { ...servedRow, name: cloud.name } : null; + return { + workspace, + host: workspace + ? { + organizationId: cloud.organizationId, + machineId: cloud.id, + name: "Cloud", + // A sandbox is reachable or it isn't; there is no offline + // device behind it to report on. + isOnline: true, + } + : null, + cloud, + isResolving: + !workspace && + cloud.status === "ready" && + (!sandboxReady || served?.isLoading === true), + }; + } let workspace: HostWorkspaceRow | null = null; let host: OrgHost | null = null; targets.forEach(({ host: target }, index) => { @@ -70,7 +127,18 @@ export function useWorkspaceHost( }); const isResolving = !workspace && - (hostsQuery.isLoading || queries.some((query) => query.isLoading)); - return { workspace, host, isResolving }; - }, [targets, queries, workspaceId, hostsQuery.isLoading]); + (hostsQuery.isLoading || + !cloudReady || + queries.some((query) => query.isLoading)); + return { workspace, host, cloud: null, isResolving }; + }, [ + cloud, + sandbox, + sandboxReady, + targets, + queries, + workspaceId, + hostsQuery.isLoading, + cloudReady, + ]); } diff --git a/apps/mobile/lib/host-service/client.ts b/apps/mobile/lib/host-service/client.ts index 783b9fd65a2..78bf0556023 100644 --- a/apps/mobile/lib/host-service/client.ts +++ b/apps/mobile/lib/host-service/client.ts @@ -7,6 +7,7 @@ import type { inferRouterOutputs } from "@trpc/server"; import superjson from "superjson"; import { getJwt } from "../auth/client"; import { getRelayUrl } from "../host/client"; +import { getSandboxAccess, sandboxPreviewToken } from "../sandbox-access"; export type HostServiceClient = TRPCClient; @@ -26,10 +27,18 @@ export type HostProjectRow = RouterOutputs["project"]["list"][number]; const clientCache = new Map(); -export function buildRelayHostUrl( +/** + * Where host-service answers for a host. A machine is reached through the + * relay by routing key; a cloud workspace's sandbox is its own host — keyed + * by the workspace's id — and is reached directly at the address the cloud + * brokered for it, so anything addressing hosts by id works for both. + */ +export function hostServiceUrl( organizationId: string, machineId: string, ): string { + const sandbox = getSandboxAccess(machineId); + if (sandbox) return sandbox.url; return `${getRelayUrl()}/hosts/${buildHostRoutingKey(organizationId, machineId)}`; } @@ -43,8 +52,14 @@ export function getHostServiceClientByUrl(hostUrl: string): HostServiceClient { url: `${hostUrl}/trpc`, transformer: superjson, headers: () => { + const headers: Record = {}; const jwt = getJwt(); - return jwt ? { Authorization: `Bearer ${jwt}` } : {}; + if (jwt) headers.Authorization = `Bearer ${jwt}`; + // The provider's edge is the only gate in front of a sandbox; + // host-service behind it checks nothing itself. + const previewToken = sandboxPreviewToken(hostUrl); + if (previewToken) headers["X-Blaxel-Preview-Token"] = previewToken; + return headers; }, }), ], diff --git a/apps/mobile/lib/sandbox-access/index.ts b/apps/mobile/lib/sandbox-access/index.ts new file mode 100644 index 00000000000..de97efcbed3 --- /dev/null +++ b/apps/mobile/lib/sandbox-access/index.ts @@ -0,0 +1,9 @@ +export { + clearSandboxAccess, + ensureSandboxAccess, + getSandboxAccess, + isSandboxHost, + pruneSandboxAccess, + type SandboxAccess, + sandboxPreviewToken, +} from "./sandbox-access"; diff --git a/apps/mobile/lib/sandbox-access/sandbox-access.ts b/apps/mobile/lib/sandbox-access/sandbox-access.ts new file mode 100644 index 00000000000..af6655d5b8c --- /dev/null +++ b/apps/mobile/lib/sandbox-access/sandbox-access.ts @@ -0,0 +1,116 @@ +import { apiClient } from "@/lib/trpc/client"; + +/** + * A sandbox has no stable address: the cloud brokers a preview URL plus a + * short-lived provider token per access, and the token — not anything + * host-service checks — is the whole of the sandbox's access control. This + * holds the latest grant per cloud workspace so the HTTP client can attach the + * token by URL and the terminal can sign each dial with a fresh one. + */ +export interface SandboxAccess { + url: string; + token: string; + expiresAt: number; +} + +/** + * Re-mint this close to expiry rather than at it: a token that dies mid-dial + * costs a reconnect, and iOS may have frozen the timer that would have + * refreshed it. + */ +const STALE_BEFORE_MS = 60_000; + +const accessByWorkspaceId = new Map(); +const previewTokenByUrl = new Map(); +const inflight = new Map>(); +/** + * Bumped whenever a workspace's grants are retired, so a mint that was + * already in flight when the retirement happened cannot land afterwards and + * quietly resurrect credentials for a workspace this client gave up. + */ +const epochByWorkspaceId = new Map(); + +export function getSandboxAccess(workspaceId: string): SandboxAccess | null { + return accessByWorkspaceId.get(workspaceId) ?? null; +} + +/** True when this host id is a cloud workspace whose sandbox has an address. */ +export function isSandboxHost(machineId: string): boolean { + return accessByWorkspaceId.has(machineId); +} + +export function sandboxPreviewToken(hostUrl: string): string | null { + return previewTokenByUrl.get(hostUrl) ?? null; +} + +function isFresh(access: SandboxAccess): boolean { + return access.expiresAt - Date.now() > STALE_BEFORE_MS; +} + +/** + * The current grant, minting a new one when there is none or it is about to + * expire. Concurrent callers share one mint. + */ +export function ensureSandboxAccess( + workspaceId: string, +): Promise { + const cached = accessByWorkspaceId.get(workspaceId); + if (cached && isFresh(cached)) return Promise.resolve(cached); + const pending = inflight.get(workspaceId); + if (pending) return pending; + + const epoch = epochByWorkspaceId.get(workspaceId) ?? 0; + const mint = apiClient.cloudWorkspace.access + .mutate({ id: workspaceId }) + .then((granted) => { + const access: SandboxAccess = { + url: granted.url, + token: granted.token, + expiresAt: new Date(granted.expiresAt).getTime(), + }; + if ((epochByWorkspaceId.get(workspaceId) ?? 0) !== epoch) { + // Retired mid-mint; hand the grant to the caller (its request is + // legitimate) but don't re-register credentials nothing owns. + return access; + } + // A renewed preview can move URLs; the old address must stop + // resolving a token or a cached client keeps authenticating with it. + const previous = accessByWorkspaceId.get(workspaceId); + if (previous && previous.url !== access.url) { + previewTokenByUrl.delete(previous.url); + } + accessByWorkspaceId.set(workspaceId, access); + previewTokenByUrl.set(access.url, access.token); + return access; + }) + .finally(() => { + inflight.delete(workspaceId); + }); + inflight.set(workspaceId, mint); + return mint; +} + +/** Forget a workspace that is gone; its URL stops carrying a token. */ +export function clearSandboxAccess(workspaceId: string): void { + epochByWorkspaceId.set( + workspaceId, + (epochByWorkspaceId.get(workspaceId) ?? 0) + 1, + ); + const access = accessByWorkspaceId.get(workspaceId); + if (access) previewTokenByUrl.delete(access.url); + accessByWorkspaceId.delete(workspaceId); +} + +/** + * Drop every grant the current cloud list no longer vouches for. The registry + * is module state, so nothing unmounts it — without this, signing out, + * switching organizations, or a workspace deleted elsewhere would leave a + * stale grant resolving its id to a sandbox URL forever. The list is the + * source of truth for which sandboxes exist, so it is also the thing that + * retires their credentials. + */ +export function pruneSandboxAccess(keepIds: ReadonlySet): void { + for (const workspaceId of accessByWorkspaceId.keys()) { + if (!keepIds.has(workspaceId)) clearSandboxAccess(workspaceId); + } +} diff --git a/apps/mobile/plans/20260817-cloud-workspaces.md b/apps/mobile/plans/20260817-cloud-workspaces.md new file mode 100644 index 00000000000..d7744442879 --- /dev/null +++ b/apps/mobile/plans/20260817-cloud-workspaces.md @@ -0,0 +1,125 @@ +# Cloud workspaces on mobile — decision log + +Goal: parity with the desktop cloud workspace experience (#6505, #6555, #6566) +in the iOS app. Read `docs/cloud-sandbox-mismatches.md` first. + +## What desktop has today + +- **List** — "Cloud" sidebar section above projects + (`DashboardSidebarCloudSection`); rows carry name from the cloud row, branch + from the sandbox, PR badge, agent status; provisioning rows spin with no + menu; failed rows still listed; rename → `cloudWorkspace.rename`, delete → + `cloudWorkspace.delete` (`useDestroyWorkspace`); gated by the + `cloud-workspaces` flag and the API's `@superset.sh` check. +- **Create** — `DevicePicker` "Cloud" sentinel → project (local host's project + list; ids are cloud `v2Projects` ids) → branch (GitHub remote branches via + the local host's `gh`, `workspaceCreation.searchRemoteBranches`) → optional + name, prompt (naming only, no agent launched) → `cloudWorkspace.create` + returns the `provisioning` row, list seeded, navigate immediately. +- **Open** — `CloudWorkspaceProvisioningState` (two steps, elapsed timer, 45s + "taking longer" hint), failed state with Remove; `list` polled at 1s while + provisioning; `SandboxAccessProvider` mints access for every ready row and + re-mints at 80% of the 10-minute TTL; HTTP carries + `X-Blaxel-Preview-Token`, the WebSocket carries `bl_preview_token` in the + query. + +## Mobile facts that force decisions + +- Home is scoped to one selected host (`useSelectedHost`) and grouped by + project; the only host-service address builder is `buildRelayHostUrl`. +- The terminal WebSocket is a browser `WebSocket` inside the WKWebView + (`scripts/generate-terminal-html.ts`); RN hands it a dial URL per attempt + (`TerminalWebView.buildDialUrl`) after a relay-only `_whoowns` preflight. +- The composer's target is a (project, online host) pair + (`useNewChatTargets`); there is no device chip. +- The composer's text launches an agent on host workspaces + (`workspaces.create` with `agents`), which desktop cloud create does not do. +- No local host: desktop reads cloud-create projects and branches through it. +- iOS suspends JS timers in the background; nothing on mobile wires React + Query to `AppState`. +- Desktop's `/terminal/` dial (`useWorkspaceWsUrl`) carries no preview + token — only `/events` does — yet terminals work there: the edge sets a + `bl_preview_token` cookie on any authenticated request and Electron replays + it on the upgrade. Mobile's WKWebView has its own cookie store, so it signs + every dial explicitly (see the mismatches doc). + +## Finding: `cloud_workspaces.project_id` points at a table that was already retired + +- #6436 (2026-08-13) decoupled the app from cloud `v2_projects` and #6439 + shrank its surface "ahead of its removal"; `v2_workspaces.project_id` and + `automations.v2_project_id` had their FKs dropped for that reason. Nothing + writes `v2_projects` rows any more (`git log -S "insert(v2Projects)"`). +- #6505 (2026-08-16) added `cloud_workspaces.project_id` as a **cascade FK to + `v2_projects`**, and `create`/`provision`/`repoForProject` all resolve the + project there. Consequences: only projects with a legacy `v2_projects` row + can be cloud-created (a project set up locally since the sync era has no row + → "Project not found in this organization", though the desktop picker + offers it); dropping `v2_projects` would cascade-delete every cloud + workspace. +- What provisioning actually consumes from the project: repo coordinates + (owner/name/defaultBranch → clone URL + App installation token) and a + display name. That is a `github_repositories` row, already org-scoped and + listable (`integration.github.listRepositories`), not a project. +- First proposal was repo-scoped (`github_repository_id`); superseded by the + environments direction below. + +## Direction (agreed 2026-08-17, not built here) + +Model **environments**, not repos or projects: an org-scoped entity holding +0..n repos (one primary), setup commands, env var names, base image/version, +later a provider snapshot per version (SUPER-1892). `cloud_workspaces` then +references `environment_id` + branch of the primary repo. v1 of that entity +should enforce exactly one primary GitHub repo — host-service assumes one +workspace = one git root, and multi-repo/no-repo push into that. Until it +exists, `project_id` → `v2_projects` stays as the interim source of the repo, +fenced as such. + +Clients must not orchestrate: create is one API call and the sandbox does the +rest. Follow-ups that fall out of that: + +- The sandbox should launch the agent from the typed prompt itself + (provision passes prompt + agent envs; host-service self-seed starts the + session), for desktop and mobile alike. +- Attachments for sandboxes belong in blob storage, not written to the host, + so a create can carry them before the sandbox exists. + +## Decisions + +| # | Decision | Choice | +| - | -------- | ------ | +| 1 | Interim create source | API procedures: `cloudWorkspace.listProjects` (v2_projects rows resolving to a repo, comment-fenced) + restored App-token `listBranches`; no host needed | +| 2 | Home placement | Cloud section pinned at the top, always visible (also in host-offline state), rows reuse `WorkspaceRow` | +| 3 | Create entry | One sectioned project sheet: Cloud section, separator, then per-online-host sections; chip reads "Project · Cloud" | +| 4 | Prompt after create | Feeds the auto-name only (parity); client stays dumb; sandbox-side agent launch is a follow-up for both apps | + +Straight ports, no decision: per-URL credentials in the host-service client, +`useCloudWorkspaces` + `useSandboxAccess` (mint for every ready row, 80% +re-mint, re-mint on foreground if past expiry, mint-if-stale at dial), +`useWorkspaceHost` cloud branch, sandbox terminal dial with `bl_preview_token` +and no `_whoowns`, fan-outs over sandbox targets, provisioning + failed +screens, rename/delete routed to the cloud router, flag + API gate. + +## PR A — verified on the simulator (2026-08-17) + +Against this worktree's local API (8401) and two live Blaxel sandboxes, signed +in as an internal test account (`claude-mobile@superset.sh`, branch DB only): + +- Cloud section on Home with the sandbox rows; a session mark on the one with + a live terminal (terminal fan-out over sandbox targets works). +- Open → terminal WebView connects with `bl_preview_token` on the dial; + composer → `terminal.send` (HTTP + preview header) → output streams back. +- New-session sheet lists the sandbox's agents; `agents.run` starts Claude in + the sandbox (which then hits the root/skip-permissions refusal — see the + mismatches doc; fixed in host-service + image, needs a rebuild). +- Provisioning row spinner + provisioning screen; failed row dot + failed + screen; Remove from the failed screen deletes (after fixing the API's + not-found classification in `deleteSandbox`). +- Rename from the actions sheet writes the cloud row; the workspace title and + list read the cloud name. +- Actions sheet: project from the sandbox, Host "Cloud", Delete offered. + +Not exercised: token refresh across a real background/foreground cycle (the +code path is `ensureSandboxAccess` at dial + invalidate on `AppState` active). +Setup traps hit: Metro `.worklets` ENOENT in a fresh worktree; local API's +GitHub App doesn't match the branch DB's installation, so create fails at the +token step here (that is what produced the failed row). diff --git a/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx index 316dd55b342..cc00245c95e 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx @@ -3,10 +3,16 @@ import { useQueryClient } from "@tanstack/react-query"; import { isAfter } from "date-fns"; import * as Haptics from "expo-haptics"; import { Stack, useFocusEffect, useRouter } from "expo-router"; +import { Cloud } from "lucide-react-native"; import { useCallback, useMemo, useState } from "react"; import { RefreshControl, useWindowDimensions, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Icon } from "@/components/ui/icon"; import { Text } from "@/components/ui/text"; +import { + type CloudWorkspaceStatus, + useCloudWorkspaceItems, +} from "@/hooks/useCloudWorkspaceItems"; import { useHostProjects } from "@/hooks/useHostProjects"; import { type HostWorkspaceItem, @@ -28,7 +34,11 @@ import { OrganizationSwitcherSheet } from "./components/OrganizationSwitcherShee import { ProjectSectionHeader } from "./components/ProjectSectionHeader"; import { ScopeBar } from "./components/ScopeBar"; import { WorkspaceRow } from "./components/WorkspaceRow"; -import { useHostTerminals } from "./hooks/useHostTerminals"; +import { useCloudRepoPrefixes } from "./hooks/useCloudRepoPrefixes"; +import { + type TerminalsHost, + useHostsTerminals, +} from "./hooks/useHostTerminals"; import { useVisibleDiffStats } from "./hooks/useVisibleDiffStats"; import { collapsedProjectKey, @@ -48,6 +58,14 @@ const MAX_VISIBLE_DIFF_STATS = 20; const NAVIGATION_BAR_HEIGHT = 44; +/** + * Cloud workspaces sit in their own section above the projects. They belong + * to no host and, since a sandbox's project row is fabricated, to no project + * the selected host knows — so they neither scope to the host filter nor + * group under a project header. Same shape as desktop's sidebar. + */ +const CLOUD_SECTION_ID = "__cloud"; + type HomeListItem = | { kind: "projectHeader"; @@ -57,8 +75,13 @@ type HomeListItem = count: number; collapsed: boolean; } - | { kind: "workspace"; workspace: HostWorkspaceItem } - | { kind: "note"; label: string }; + | { + kind: "workspace"; + workspace: HostWorkspaceItem; + cloudStatus?: CloudWorkspaceStatus; + } + | { kind: "note"; label: string } + | { kind: "hostOffline"; hostName: string }; function homeListItemKey(item: HomeListItem): string { switch (item.kind) { @@ -66,6 +89,8 @@ function homeListItemKey(item: HomeListItem): string { return `project:${item.projectId}`; case "workspace": return `ws:${item.workspace.id}`; + case "hostOffline": + return "host-offline"; default: return `note:${item.label}`; } @@ -93,8 +118,32 @@ export function HomeScreen() { const selectedHost = useSelectedHost(); const pinnedAt = usePinnedWorkspacesStore((state) => state.pinnedAt); const { workspaces, isReady, cache } = useHostWorkspaces(selectedHost); + const { + items: cloudItems, + targets: sandboxes, + cache: cloudCache, + isReady: cloudReady, + } = useCloudWorkspaceItems(); + // Every addressed sandbox is a host of its own for the terminal fan-out, + // so cloud rows get session marks and attention like any other row. Lazier + // than the machine host on purpose: each sandbox is its own request, and a + // phone paying N requests every 5s for list decoration is the mistake + // desktop just walked back (#6570). Opening a workspace speeds up its own + // host via the shared query key. + const terminalHosts = useMemo( + () => [ + ...(selectedHost ? [selectedHost] : []), + ...sandboxes.map((sandbox) => ({ + organizationId: sandbox.organizationId, + machineId: sandbox.workspaceId, + isOnline: true, + refetchIntervalMs: 30_000, + })), + ], + [selectedHost, sandboxes], + ); const { terminalsByWorkspace, attentionByWorkspace } = - useHostTerminals(selectedHost); + useHostsTerminals(terminalHosts); // Projects are fully local — served by the selected host, not the cloud. const { projects } = useHostProjects(selectedHost); @@ -175,6 +224,39 @@ export function HomeScreen() { const listItems = useMemo(() => { const items: HomeListItem[] = []; + + const cloudPool = searching ? cloudItems.filter(matchesQuery) : cloudItems; + if (cloudPool.length > 0) { + const isCollapsed = + !searching && + collapseHydrated && + !!collapsed[collapsedProjectKey(CLOUD_SECTION_ID, CLOUD_SECTION_ID)]; + items.push({ + kind: "projectHeader", + projectId: CLOUD_SECTION_ID, + name: "Cloud", + count: cloudPool.length, + collapsed: isCollapsed, + }); + if (!isCollapsed) { + for (const workspace of [...cloudPool].sort(byPinThenActivity)) { + items.push({ + kind: "workspace", + workspace, + cloudStatus: workspace.cloud.status, + }); + } + } + } + + // The selected machine's rows follow. When it is offline the section + // gives way to the offline placeholder, but the cloud rows above it stay + // — a sandbox doesn't care which of your machines is awake. + if (selectedHost && !selectedHost.isOnline) { + items.push({ kind: "hostOffline", hostName: selectedHost.name }); + return items; + } + const onThisHost = liveWorkspaces.filter( (workspace) => workspace.hostId === selectedHost?.machineId, ); @@ -268,6 +350,7 @@ export function HomeScreen() { return items; }, [ liveWorkspaces, + cloudItems, selectedHost, projects, searching, @@ -278,9 +361,14 @@ export function HomeScreen() { collapseHydrated, ]); + const composerWorkspaces = useMemo( + () => [...workspaces, ...cloudItems], + [workspaces, cloudItems], + ); const workspacesById = useMemo( - () => new Map(workspaces.map((workspace) => [workspace.id, workspace])), - [workspaces], + () => + new Map(composerWorkspaces.map((workspace) => [workspace.id, workspace])), + [composerWorkspaces], ); const pullRequestsByRepoBranch = useMemo(() => { @@ -317,10 +405,15 @@ export function HomeScreen() { return byRepoBranch; }, [pullRequests]); + const resolveAnyHostUrl = useCallback( + (hostId: string) => + cache.resolveHostUrl(hostId) ?? cloudCache.resolveHostUrl(hostId), + [cache, cloudCache], + ); const diffStats = useVisibleDiffStats({ visibleIds, workspacesById, - resolveHostUrl: cache.resolveHostUrl, + resolveHostUrl: resolveAnyHostUrl, }); const onViewableItemsChanged = useCallback( @@ -365,17 +458,20 @@ export function HomeScreen() { // Projects are fully local: PR rows are matched by repo coordinates // parsed from the PR URL (cloud repo UUIDs aren't known host-side). + // Cloud rows' projects come from the API instead. + const cloudRepoPrefixes = useCloudRepoPrefixes(cloudItems); const repoPrefixesByProject = useMemo( () => - new Map( - projects.map((project) => [ + new Map([ + ...cloudRepoPrefixes, + ...projects.map((project): [string, string | null] => [ project.id, project.repoOwner && project.repoName ? `https://github.com/${project.repoOwner}/${project.repoName}/`.toLowerCase() : null, ]), - ), - [projects], + ]), + [projects, cloudRepoPrefixes], ); const renderItem = useCallback( @@ -387,21 +483,43 @@ export function HomeScreen() { ); } + if (item.kind === "hostOffline") { + return ( + + + + ); + } if (item.kind === "projectHeader") { + const isCloudSection = item.projectId === CLOUD_SECTION_ID; return ( + ) : undefined + } count={item.count} collapsed={item.collapsed} onToggle={() => { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - toggleProject(selectedHost?.machineId ?? "", item.projectId); + toggleProject( + isCloudSection + ? CLOUD_SECTION_ID + : (selectedHost?.machineId ?? ""), + item.projectId, + ); }} /> ); } - const { workspace } = item; + const { workspace, cloudStatus } = item; const repoPrefix = workspace.projectId ? repoPrefixesByProject.get(workspace.projectId) : undefined; @@ -416,9 +534,10 @@ export function HomeScreen() { : undefined } diffStats={diffStats.get(workspace.id) ?? null} - cache={cache} + cache={cloudStatus === undefined ? cache : cloudCache} attention={attentionByWorkspace.get(workspace.id) ?? null} sessions={terminalsByWorkspace.get(workspace.id) ?? []} + cloudStatus={cloudStatus} /> ); }, @@ -427,6 +546,7 @@ export function HomeScreen() { repoPrefixesByProject, diffStats, cache, + cloudCache, attentionByWorkspace, terminalsByWorkspace, toggleProject, @@ -483,7 +603,10 @@ export function HomeScreen() { onChangeText={(event) => setSearchQuery(event.nativeEvent.text)} onCancelButtonPress={() => setSearchQuery("")} /> - {selectedHost && !selectedHost.isOnline ? ( + {selectedHost && + !selectedHost.isOnline && + cloudReady && + cloudItems.length === 0 ? ( } ListEmptyComponent={ - isReady && hasHydrated && !isLoadingOrganizations ? ( + isReady && cloudReady && hasHydrated && !isLoadingOrganizations ? ( {searching @@ -527,7 +650,10 @@ export function HomeScreen() { } /> )} - + {/* Cloud rows included: the row's "+" targets a workspace by id, and + the composer has to find a sandbox workspace as readily as a + machine's to start an agent in it. */} + ({ machineId: host.machineId, name: host.name, - hostUrl: buildRelayHostUrl(host.organizationId, host.machineId), + hostUrl: hostServiceUrl(host.organizationId, host.machineId), })), [hosts, presence], ); diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useStartWorkspaceTerminal/useStartWorkspaceTerminal.ts b/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useStartWorkspaceTerminal/useStartWorkspaceTerminal.ts index b149abaf01d..d69cf03fd79 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useStartWorkspaceTerminal/useStartWorkspaceTerminal.ts +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useStartWorkspaceTerminal/useStartWorkspaceTerminal.ts @@ -4,8 +4,8 @@ import { Alert } from "react-native"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import type { HostWorkspaceItem } from "@/hooks/useHostWorkspaces"; import { - buildRelayHostUrl, getHostServiceClientByUrl, + hostServiceUrl, } from "@/lib/host-service/client"; import { getHostTerminalsQueryKey } from "../../../../hooks/useHostTerminals"; import type { ChatTarget } from "../../../../stores/chatTargetStore"; @@ -40,10 +40,7 @@ export function useStartWorkspaceTerminal(workspaces: HostWorkspaceItem[]) { "Attachments are not supported in terminal sessions yet", ); } - const hostUrl = buildRelayHostUrl( - workspace.organizationId, - target.hostId, - ); + const hostUrl = hostServiceUrl(workspace.organizationId, target.hostId); const client = getHostServiceClientByUrl(hostUrl); const text = message.text.trim(); diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/ProjectSectionHeader/ProjectSectionHeader.tsx b/apps/mobile/screens/(authenticated)/(home)/home/components/ProjectSectionHeader/ProjectSectionHeader.tsx index eab7212aaf2..b19b9cb0235 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/ProjectSectionHeader/ProjectSectionHeader.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/ProjectSectionHeader/ProjectSectionHeader.tsx @@ -1,4 +1,5 @@ import { ChevronDown, ChevronRight } from "lucide-react-native"; +import type { ReactNode } from "react"; import { Pressable, View } from "react-native"; import { Text } from "@/components/ui/text"; import { useTheme } from "@/hooks/useTheme"; @@ -7,6 +8,8 @@ import { ProjectAvatar } from "@/screens/(authenticated)/(home)/filter/component interface ProjectSectionHeaderProps { name: string; iconUrl?: string | null; + /** Replaces the project avatar for sections that aren't a project. */ + icon?: ReactNode; count: number; collapsed: boolean; onToggle: () => void; @@ -21,6 +24,7 @@ interface ProjectSectionHeaderProps { export function ProjectSectionHeader({ name, iconUrl, + icon, count, collapsed, onToggle, @@ -38,7 +42,7 @@ export function ProjectSectionHeader({ doesn't reach the underlying SVG, so it renders nothing. */} - + {icon ?? } {name} diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/WorkspaceRow.tsx b/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/WorkspaceRow.tsx index ef8765ea44b..7598d175cca 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/WorkspaceRow.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/WorkspaceRow.tsx @@ -5,6 +5,7 @@ import { ActivityIndicator, Pressable, View } from "react-native"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { Text } from "@/components/ui/text"; +import type { CloudWorkspaceStatus } from "@/hooks/useCloudWorkspaceItems"; import type { HostWorkspaceItem, HostWorkspacesCacheOps, @@ -38,6 +39,7 @@ export function WorkspaceRow({ cache, attention, sessions, + cloudStatus, }: { workspace: HostWorkspaceItem; pullRequest?: SelectGithubPullRequest; @@ -45,6 +47,8 @@ export function WorkspaceRow({ cache: HostWorkspacesCacheOps; attention?: TerminalAttention | null; sessions: TerminalRowData[]; + /** Set for a cloud workspace; drives the row's pending/failed treatment. */ + cloudStatus?: CloudWorkspaceStatus; }) { const router = useRouter(); const theme = useTheme(); @@ -55,18 +59,30 @@ export function WorkspaceRow({ const targeted = useChatTargetStore( (state) => state.target?.workspaceId === workspace.id, ); - const canChat = workspace.hostReachable && workspace.worktreeExists !== false; + const canChat = + workspace.hostReachable && + workspace.worktreeExists !== false && + (cloudStatus === undefined || cloudStatus === "ready"); const { isDeleting, renameWorkspace, deleteWorkspace, copyId, shareWorkspace, - } = useWorkspaceRowActions(workspace, cache); + } = useWorkspaceRowActions(workspace, cache, cloudStatus); return ( void renameWorkspace()} onDelete={deleteWorkspace} onCopyId={copyId} @@ -93,7 +109,7 @@ export function WorkspaceRow({ - ) : attention === "working" ? ( + ) : attention === "working" || cloudStatus === "provisioning" ? ( @@ -129,7 +145,7 @@ export function WorkspaceRow({ - ) : attention === "failed" ? ( + ) : attention === "failed" || cloudStatus === "failed" ? ( diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx b/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx index fc46785f9da..f802db28c2e 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/components/WorkspaceRowMenu/WorkspaceRowMenu.tsx @@ -2,6 +2,7 @@ import { Link } from "expo-router"; import type { ReactNode } from "react"; export function WorkspaceRowMenu({ + canRename, canDelete, onRename, onDelete, @@ -9,6 +10,7 @@ export function WorkspaceRowMenu({ onShare, children, }: { + canRename: boolean; canDelete: boolean; onRename: () => void; onDelete: () => void; @@ -26,9 +28,13 @@ export function WorkspaceRowMenu({ > {children} - - Rename - + {/* Each action is its own direct child: Link.Menu drops anything + wrapped in a Fragment. */} + {canRename ? ( + + Rename + + ) : null} {canDelete ? ( Delete diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/hooks/useWorkspaceRowActions/useWorkspaceRowActions.ts b/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/hooks/useWorkspaceRowActions/useWorkspaceRowActions.ts index 2ca3e1ab40c..309dda775e4 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/hooks/useWorkspaceRowActions/useWorkspaceRowActions.ts +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/WorkspaceRow/hooks/useWorkspaceRowActions/useWorkspaceRowActions.ts @@ -2,6 +2,8 @@ import { prompt } from "@superset/alert-prompt"; import * as Clipboard from "expo-clipboard"; import { useState } from "react"; import { Alert, Share } from "react-native"; +import { useCloudWorkspaceActions } from "@/hooks/useCloudWorkspaceActions"; +import type { CloudWorkspaceStatus } from "@/hooks/useCloudWorkspaceItems"; import type { HostWorkspaceItem, HostWorkspacesCacheOps, @@ -20,12 +22,16 @@ interface DestroyOptions { export function useWorkspaceRowActions( workspace: HostWorkspaceItem, cache: HostWorkspacesCacheOps, + /** Set for a cloud workspace, whose name and lifetime the API owns. */ + cloudStatus?: CloudWorkspaceStatus, ) { const [isDeleting, setIsDeleting] = useState(false); + const cloud = useCloudWorkspaceActions(); + const isCloud = cloudStatus !== undefined; const renameWorkspace = async () => { - const hostUrl = cache.resolveHostUrl(workspace.hostId); - if (!hostUrl) { + const hostUrl = isCloud ? null : cache.resolveHostUrl(workspace.hostId); + if (!isCloud && !hostUrl) { Alert.alert("Host is not online"); return; } @@ -38,16 +44,44 @@ export function useWorkspaceRowActions( const trimmed = name?.trim(); if (!trimmed || trimmed === workspace.name) return; try { - await getHostServiceClientByUrl(hostUrl).workspace.update.mutate({ - id: workspace.id, - name: trimmed, - }); + if (isCloud) { + // The cloud row owns the name; the sandbox's copy is scratch. + await cloud.rename(workspace.id, trimmed); + return; + } + if (hostUrl) { + await getHostServiceClientByUrl(hostUrl).workspace.update.mutate({ + id: workspace.id, + name: trimmed, + }); + } } catch { Alert.alert("Rename failed"); } cache.invalidateHost(workspace.hostId); }; + const deleteCloudWorkspace = () => { + Alert.alert( + "Delete cloud workspace", + `Delete "${workspace.name}"? This shuts down its sandbox and everything in it.`, + [ + { style: "cancel", text: "Cancel" }, + { + onPress: () => { + setIsDeleting(true); + void cloud + .remove(workspace.id) + .catch(() => Alert.alert("Delete failed")) + .finally(() => setIsDeleting(false)); + }, + style: "destructive", + text: "Delete", + }, + ], + ); + }; + const destroyWorkspace = async ({ force, skipTeardown }: DestroyOptions) => { const hostUrl = cache.resolveHostUrl(workspace.hostId); if (!hostUrl) { @@ -104,6 +138,12 @@ export function useWorkspaceRowActions( const deleteWorkspace = () => { if (isDeleting) return; + if (isCloud) { + // Deleting through the sandbox would remove the row inside it and + // leave the sandbox running — and billing. Only the API can destroy. + deleteCloudWorkspace(); + return; + } if (!cache.resolveHostUrl(workspace.hostId)) { Alert.alert("Host is not online"); return; diff --git a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useCloudRepoPrefixes/index.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useCloudRepoPrefixes/index.ts new file mode 100644 index 00000000000..073eb8ebdcf --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useCloudRepoPrefixes/index.ts @@ -0,0 +1 @@ +export { useCloudRepoPrefixes } from "./useCloudRepoPrefixes"; diff --git a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useCloudRepoPrefixes/useCloudRepoPrefixes.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useCloudRepoPrefixes/useCloudRepoPrefixes.ts new file mode 100644 index 00000000000..694bd8120bc --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useCloudRepoPrefixes/useCloudRepoPrefixes.ts @@ -0,0 +1,54 @@ +import { useQueries } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { apiClient } from "@/lib/trpc/client"; + +/** + * Repo URL prefix per cloud project, for matching PR rows the way host + * projects are matched: a cloud workspace's project isn't served by the + * selected host, so its repo coordinates come from the API instead. + */ +export function useCloudRepoPrefixes( + cloudWorkspaces: Array<{ + projectId: string | null; + organizationId: string; + }>, +): Map { + const projects = useMemo(() => { + const seen = new Map(); + for (const row of cloudWorkspaces) { + if (row.projectId && !seen.has(row.projectId)) { + seen.set(row.projectId, row.organizationId); + } + } + return [...seen.entries()].map(([projectId, organizationId]) => ({ + projectId, + organizationId, + })); + }, [cloudWorkspaces]); + + const queries = useQueries({ + queries: projects.map((project) => ({ + queryKey: [ + "cloud", + "cloudWorkspace", + "repoForProject", + project.projectId, + ], + staleTime: Number.POSITIVE_INFINITY, + queryFn: () => apiClient.cloudWorkspace.repoForProject.query(project), + })), + }); + + return useMemo(() => { + const prefixes = new Map(); + projects.forEach((project, index) => { + const repo = queries[index]?.data; + if (!repo) return; + prefixes.set( + project.projectId, + `https://github.com/${repo.owner}/${repo.name}/`.toLowerCase(), + ); + }); + return prefixes; + }, [projects, queries]); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostTerminals/index.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostTerminals/index.ts index fa2d51f27bf..5fbd8b3ff51 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostTerminals/index.ts +++ b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostTerminals/index.ts @@ -4,5 +4,6 @@ export { type TerminalRowData, type TerminalsHost, type UseHostTerminalsResult, + useHostsTerminals, useHostTerminals, } from "./useHostTerminals"; diff --git a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostTerminals/useHostTerminals.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostTerminals/useHostTerminals.ts index decac3835e8..e7ea5f565c5 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostTerminals/useHostTerminals.ts +++ b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostTerminals/useHostTerminals.ts @@ -1,8 +1,8 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQueries } from "@tanstack/react-query"; import { useMemo } from "react"; import { - buildRelayHostUrl, getHostServiceClientByUrl, + hostServiceUrl, } from "@/lib/host-service/client"; import { useTerminalSeenStore } from "@/screens/(authenticated)/stores/terminalSeenStore"; @@ -10,6 +10,13 @@ export interface TerminalsHost { organizationId: string; machineId: string; isOnline: boolean; + /** + * Poll cadence override. Session marks on a list can be lazier than the + * tab strip of an open workspace — and the query key is shared, so when a + * workspace screen also observes this host, its faster interval wins for + * as long as it is open. + */ + refetchIntervalMs?: number; } export type TerminalAttention = "working" | "permission" | "failed" | "review"; @@ -79,81 +86,94 @@ export interface UseHostTerminalsResult { } /** - * Live terminals served by one host over the relay — the host-wide - * `terminal.list` joined with `terminalAgents.list` by terminalId. Terminals - * are the rows (they're the attachable unit and outlive their agent); - * bindings supply what terminals can't: agent identity and attention. + * Live terminals served by a set of hosts — the host-wide `terminal.list` + * joined with `terminalAgents.list` by terminalId, merged across hosts. + * Terminals are the rows (they're the attachable unit and outlive their + * agent); bindings supply what terminals can't: agent identity and attention. + * Home passes the selected machine plus every addressed sandbox, so cloud + * rows carry session marks the same way. */ -export function useHostTerminals( - host: TerminalsHost | null, +export function useHostsTerminals( + hosts: TerminalsHost[], ): UseHostTerminalsResult { - const hostUrl = host?.isOnline - ? buildRelayHostUrl(host.organizationId, host.machineId) - : null; const terminalSeenAt = useTerminalSeenStore((state) => state.terminalSeenAt); - const query = useQuery({ - queryKey: getHostTerminalsQueryKey(host?.machineId ?? null), - enabled: hostUrl !== null, - refetchInterval: TERMINALS_REFETCH_INTERVAL_MS, - refetchIntervalInBackground: false, - refetchOnWindowFocus: false, - retry: 1, - networkMode: "always" as const, - queryFn: async () => { - if (!hostUrl) return { sessions: [], bindings: [] }; - const client = getHostServiceClientByUrl(hostUrl); - const [listed, bindings] = await Promise.all([ - client.terminal.list.query({}), - client.terminalAgents.list.query(), - ]); - return { sessions: listed.sessions, bindings }; - }, + const online = useMemo( + () => + hosts + .filter((host) => host.isOnline) + .map((host) => ({ + machineId: host.machineId, + hostUrl: hostServiceUrl(host.organizationId, host.machineId), + refetchIntervalMs: + host.refetchIntervalMs ?? TERMINALS_REFETCH_INTERVAL_MS, + })), + [hosts], + ); + + const queries = useQueries({ + queries: online.map((host) => ({ + queryKey: getHostTerminalsQueryKey(host.machineId), + refetchInterval: host.refetchIntervalMs, + refetchIntervalInBackground: false, + refetchOnWindowFocus: false, + retry: 1, + networkMode: "always" as const, + queryFn: async () => { + const client = getHostServiceClientByUrl(host.hostUrl); + const [listed, bindings] = await Promise.all([ + client.terminal.list.query({}), + client.terminalAgents.list.query(), + ]); + return { sessions: listed.sessions, bindings }; + }, + })), }); return useMemo(() => { const terminalsByWorkspace = new Map(); const attentionByWorkspace = new Map(); - const online = host?.isOnline ?? false; - const bindingsByTerminal = new Map( - (online ? (query.data?.bindings ?? []) : []).map((binding) => [ - binding.terminalId, - binding, - ]), - ); - for (const session of online ? (query.data?.sessions ?? []) : []) { - if (session.exited) continue; - const binding = bindingsByTerminal.get(session.terminalId); - const attention = binding - ? attentionFromEvent( - binding.lastEventType, - binding.lastEventAt, - terminalSeenAt[session.terminalId], - ) - : null; - const row: TerminalRowData = { - terminalId: session.terminalId, - workspaceId: session.workspaceId, - title: session.title ?? (binding ? binding.agentId : "Terminal"), - ts: binding?.lastEventAt ?? session.createdAt, - createdAt: session.createdAt, - lastEventAt: binding?.lastEventAt ?? null, - agentId: binding?.agentId ?? null, - attention, - }; - const group = terminalsByWorkspace.get(session.workspaceId); - if (group) group.push(row); - else terminalsByWorkspace.set(session.workspaceId, [row]); + for (const query of queries) { + const bindingsByTerminal = new Map( + (query.data?.bindings ?? []).map((binding) => [ + binding.terminalId, + binding, + ]), + ); + for (const session of query.data?.sessions ?? []) { + if (session.exited) continue; + const binding = bindingsByTerminal.get(session.terminalId); + const attention = binding + ? attentionFromEvent( + binding.lastEventType, + binding.lastEventAt, + terminalSeenAt[session.terminalId], + ) + : null; + const row: TerminalRowData = { + terminalId: session.terminalId, + workspaceId: session.workspaceId, + title: session.title ?? (binding ? binding.agentId : "Terminal"), + ts: binding?.lastEventAt ?? session.createdAt, + createdAt: session.createdAt, + lastEventAt: binding?.lastEventAt ?? null, + agentId: binding?.agentId ?? null, + attention, + }; + const group = terminalsByWorkspace.get(session.workspaceId); + if (group) group.push(row); + else terminalsByWorkspace.set(session.workspaceId, [row]); - // Stale statuses are worse than none: attention only from live data. - if (attention) { - const existing = attentionByWorkspace.get(session.workspaceId); - if ( - !existing || - ATTENTION_PRIORITY[attention] > ATTENTION_PRIORITY[existing] - ) { - attentionByWorkspace.set(session.workspaceId, attention); + // Stale statuses are worse than none: attention only from live data. + if (attention) { + const existing = attentionByWorkspace.get(session.workspaceId); + if ( + !existing || + ATTENTION_PRIORITY[attention] > ATTENTION_PRIORITY[existing] + ) { + attentionByWorkspace.set(session.workspaceId, attention); + } } } } @@ -163,14 +183,15 @@ export function useHostTerminals( return { terminalsByWorkspace, attentionByWorkspace, - isReady: hostUrl === null || query.isSuccess || query.isError, + isReady: queries.every((query) => query.isSuccess || query.isError), }; - }, [ - query.data, - query.isSuccess, - query.isError, - host?.isOnline, - hostUrl, - terminalSeenAt, - ]); + }, [queries, terminalSeenAt]); +} + +/** One host's terminals — see `useHostsTerminals`. */ +export function useHostTerminals( + host: TerminalsHost | null, +): UseHostTerminalsResult { + const hosts = useMemo(() => (host ? [host] : []), [host]); + return useHostsTerminals(hosts); } diff --git a/apps/mobile/screens/(authenticated)/settings/presets/PresetsScreen.tsx b/apps/mobile/screens/(authenticated)/settings/presets/PresetsScreen.tsx index 5db68ee94f0..d03d416aa4e 100644 --- a/apps/mobile/screens/(authenticated)/settings/presets/PresetsScreen.tsx +++ b/apps/mobile/screens/(authenticated)/settings/presets/PresetsScreen.tsx @@ -5,8 +5,8 @@ import { useHostsPresence } from "@/hooks/useHostsPresence"; import { useOrgHosts } from "@/hooks/useOrgHosts"; import { useTheme } from "@/hooks/useTheme"; import { - buildRelayHostUrl, getHostServiceClientByUrl, + hostServiceUrl, } from "@/lib/host-service/client"; import { AgentMark } from "@/screens/(authenticated)/(home)/new-session/agent"; import { ListRow } from "@/screens/(authenticated)/components/ListRow"; @@ -37,7 +37,7 @@ export function PresetsScreen() { networkMode: "always" as const, queryFn: () => getHostServiceClientByUrl( - buildRelayHostUrl(host.organizationId, host.machineId), + hostServiceUrl(host.organizationId, host.machineId), ).settings.agentConfigs.list.query(), })), }); diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx index e953bc802d7..53d1fe93e81 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx @@ -1,4 +1,3 @@ -import { buildHostRoutingKey } from "@superset/shared/host-routing"; import { useQueryClient } from "@tanstack/react-query"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { CloudOff, Plus, SquareTerminal } from "lucide-react-native"; @@ -16,8 +15,8 @@ import { Icon } from "@/components/ui/icon"; import { Text } from "@/components/ui/text"; import { useWorkspaceHost } from "@/hooks/useWorkspaceHost"; import { - buildRelayHostUrl, getHostServiceClientByUrl, + hostServiceUrl, } from "@/lib/host-service/client"; import { getHostTerminalsQueryKey, @@ -28,6 +27,7 @@ import { PressableScale } from "@/screens/(authenticated)/components/PressableSc import { useAppReviewPrompt } from "@/screens/(authenticated)/hooks/useAppReviewPrompt"; import { useTerminalSeenStore } from "@/screens/(authenticated)/stores/terminalSeenStore"; import { useTerminalTabOrderStore } from "@/screens/(authenticated)/stores/terminalTabOrderStore"; +import { CloudWorkspaceProvisioningState } from "../components/CloudWorkspaceProvisioningState"; import { PullRequestsButton } from "../components/PullRequestsButton"; import { TerminalComposer, @@ -70,7 +70,7 @@ export function WorkspaceScreen() { const insets = useSafeAreaInsets(); const queryClient = useQueryClient(); - const { workspace, host, isResolving } = useWorkspaceHost(id ?? null); + const { workspace, host, cloud, isResolving } = useWorkspaceHost(id ?? null); const { terminalsByWorkspace, isReady } = useHostTerminals(host); const pullRequests = useWorkspacePullRequests(id ?? null); @@ -103,10 +103,7 @@ export function WorkspaceScreen() { }, [pickedTerminalId, params.tab, rows]); const hostUrl = host - ? buildRelayHostUrl(host.organizationId, host.machineId) - : null; - const routingKey = host - ? buildHostRoutingKey(host.organizationId, host.machineId) + ? hostServiceUrl(host.organizationId, host.machineId) : null; // The + sheet lands back here via dismissTo with the new session in @@ -241,7 +238,7 @@ export function WorkspaceScreen() { }, []); const banner = STATE_BANNERS[connectionState]; - const showComposer = activeTerminalId !== null && routingKey !== null; + const showComposer = activeTerminalId !== null && host !== null; const attachmentTarget = useMemo( () => @@ -266,21 +263,25 @@ export function WorkspaceScreen() { the back button under iOS 26's floating bar items. */} - {workspace?.name ?? ""} + {workspace?.name ?? cloud?.name ?? ""} - + {/* A cloud workspace exists on screen before anything serves it; the + tab strip would only offer sessions on a sandbox that isn't up. */} + {cloud && !workspace ? null : ( + + )} {banner && activeTerminalId ? ( @@ -306,13 +307,13 @@ export function WorkspaceScreen() { marginBottom: showComposer ? composerHeight + composerBottom : 0, }} > - {activeTerminalId && routingKey && id ? ( + {activeTerminalId && host && id ? ( <> @@ -327,6 +328,8 @@ export function WorkspaceScreen() { /> ) : null} + ) : cloud && !workspace ? ( + ) : isResolving || (!isReady && host) ? ( diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/actions/WorkspaceActionsSheet.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/actions/WorkspaceActionsSheet.tsx index 805673a6e6a..ad6ee6d0118 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/actions/WorkspaceActionsSheet.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/actions/WorkspaceActionsSheet.tsx @@ -13,6 +13,7 @@ import { Text } from "@/components/ui/text"; import { useHostProjects } from "@/hooks/useHostProjects"; import { useTheme } from "@/hooks/useTheme"; import { useWorkspaceHost } from "@/hooks/useWorkspaceHost"; +import { isSandboxHost } from "@/lib/sandbox-access"; import { ProjectAvatar } from "@/screens/(authenticated)/(home)/filter/components/ProjectAvatar"; import { usePinnedWorkspacesStore } from "@/screens/(authenticated)/stores/pinnedWorkspacesStore"; import { useWorkspaceChangeset } from "../hooks/useWorkspaceChangeset"; @@ -88,7 +89,10 @@ export function WorkspaceActionsSheet() { ); const togglePin = usePinnedWorkspacesStore((state) => state.togglePin); - const canDelete = workspace ? workspace.type !== "main" : false; + // A cloud workspace is served as `main` because its checkout is the repo, + // but deleting it deletes the sandbox, not somebody's base checkout. + const isCloud = host !== null && isSandboxHost(host.machineId); + const canDelete = workspace ? workspace.type !== "main" || isCloud : false; const project = workspace?.projectId ? projects.find((candidate) => candidate.id === workspace.projectId) : undefined; @@ -163,7 +167,9 @@ export function WorkspaceActionsSheet() { ) : null} - {host ? : null} + {/* A sandbox isn't one of your machines; naming it as the host says + nothing the Cloud section didn't. */} + {host && !isCloud ? : null} {changeset.files.length > 0 ? ( ; + } + + // `ready` means the provider handed back a preview URL, which is a step + // short of host-service answering on it. Until the sandbox serves the + // workspace this screen keeps rendering, so the second step is the honest + // place to be. + const sandboxReady = cloud.status !== "provisioning"; + + return ( + + + + {/* The steps stay left-aligned inside their own column so the icons + line up; the column itself sits centered like everything else. */} + + + + + + {formatElapsed(elapsed)} + + {elapsed >= STUCK_AFTER_SECONDS ? ( + + Taking longer than usual. It keeps going if you leave. + + ) : null} + + ); +} + +/** + * Provisioning gave up. The row is all that is left of the workspace — the + * sandbox behind it was torn down when it failed — so the only thing to offer + * is disposing of it, which is also the only way to clear it from the list. + */ +function CloudWorkspaceFailedState({ cloud }: { cloud: CloudWorkspaceRow }) { + const router = useRouter(); + const { remove: removeCloudWorkspace } = useCloudWorkspaceActions(); + const [isDeleting, setIsDeleting] = useState(false); + + const remove = async () => { + setIsDeleting(true); + try { + await removeCloudWorkspace(cloud.id); + router.back(); + } catch { + Alert.alert("Delete failed"); + setIsDeleting(false); + } + }; + + return ( + + + + + Nothing is running. Remove it and create a new one. + + + + ); +} + +function Frame({ + icon, + iconClassName, + children, +}: { + icon: LucideIcon; + iconClassName: string; + children: ReactNode; +}) { + return ( + + + + {children} + + + ); +} + +function Heading({ title, name }: { title: string; name: string }) { + return ( + + {title} + + {name || "Untitled workspace"} + + + ); +} + +function BranchLine({ branch }: { branch: string }) { + if (!branch) return null; + return ( + + + + {branch} + + + ); +} + +type StepState = "done" | "active" | "pending"; + +function StepRow({ label, state }: { label: string; state: StepState }) { + return ( + + + + {label} + + + ); +} + +function StepIcon({ state }: { state: StepState }) { + if (state === "done") { + return ( + + + + ); + } + if (state === "active") { + return ( + + + + ); + } + return ( + + + + ); +} + +function formatElapsed(seconds: number): string { + const total = Math.max(0, Math.floor(seconds)); + const minutes = Math.floor(total / 60); + return `${minutes}:${(total % 60).toString().padStart(2, "0")}`; +} + +/** + * Counts from when this screen appeared, not from the row's `createdAt`: an + * hours-old cloud workspace renders this too while its sleeping sandbox wakes, + * and "1:47:12" would be describing the workspace's age, not the wait. + */ +function useElapsedSeconds(): number { + const [elapsed, setElapsed] = useState(0); + useEffect(() => { + const startedAt = Date.now(); + const id = setInterval( + () => setElapsed((Date.now() - startedAt) / 1000), + 250, + ); + return () => clearInterval(id); + }, []); + return elapsed; +} diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/components/CloudWorkspaceProvisioningState/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/components/CloudWorkspaceProvisioningState/index.ts new file mode 100644 index 00000000000..138722c784d --- /dev/null +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/components/CloudWorkspaceProvisioningState/index.ts @@ -0,0 +1 @@ +export { CloudWorkspaceProvisioningState } from "./CloudWorkspaceProvisioningState"; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalWebView/TerminalWebView.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalWebView/TerminalWebView.tsx index e000849df16..af637d1d168 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalWebView/TerminalWebView.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalWebView/TerminalWebView.tsx @@ -1,3 +1,4 @@ +import { buildHostRoutingKey } from "@superset/shared/host-routing"; import { forwardRef, useCallback, @@ -10,6 +11,7 @@ import { AppState } from "react-native"; import { WebView, type WebViewMessageEvent } from "react-native-webview"; import { withUniwind } from "uniwind"; import { getHostAuthToken, getRelayUrl } from "@/lib/host/client"; +import { ensureSandboxAccess, isSandboxHost } from "@/lib/sandbox-access"; const StyledWebView = withUniwind(WebView); @@ -38,10 +40,16 @@ export interface TerminalWebViewHandle { retry: () => void; } +export interface TerminalHost { + organizationId: string; + /** A machine id, or a cloud workspace's id when its sandbox is the host. */ + machineId: string; +} + interface TerminalWebViewProps { workspaceId: string; terminalId: string; - routingKey: string; + host: TerminalHost; onStateChange: (state: TerminalConnectionState) => void; onControl: (message: TerminalControlMessage) => void; } @@ -62,7 +70,7 @@ export const TerminalWebView = forwardRef< TerminalWebViewHandle, TerminalWebViewProps >(function TerminalWebView( - { workspaceId, terminalId, routingKey, onStateChange, onControl }, + { workspaceId, terminalId, host, onStateChange, onControl }, ref, ) { const webViewRef = useRef(null); @@ -89,19 +97,34 @@ export const TerminalWebView = forwardRef< webViewRef.current?.postMessage(JSON.stringify(message)); }, []); + // Signed per attempt, never cached: the relay wants a fresh JWT and a + // sandbox's edge token expires, so a redial after a long background must + // re-mint rather than reuse the URL that worked last time. const buildDialUrl = useCallback( async (replay: "0" | "1"): Promise => { const token = await getHostAuthToken(); - const base = getRelayUrl().replace(/^http/, "ws"); const query = [ `workspaceId=${encodeURIComponent(workspaceId)}`, "themeType=dark", ...(replay === "0" ? ["replay=0"] : []), `token=${encodeURIComponent(token)}`, - ].join("&"); - return `${base}/hosts/${routingKey}/terminal/${encodeURIComponent(terminalId)}?${query}`; + ]; + const path = `/terminal/${encodeURIComponent(terminalId)}`; + if (isSandboxHost(host.machineId)) { + // A browser can't put a header on a WebSocket upgrade, so the + // provider's edge reads its token from the query string here. + const access = await ensureSandboxAccess(host.machineId); + query.push(`bl_preview_token=${encodeURIComponent(access.token)}`); + return `${access.url.replace(/^http/, "ws")}${path}?${query.join("&")}`; + } + const base = getRelayUrl().replace(/^http/, "ws"); + const routingKey = buildHostRoutingKey( + host.organizationId, + host.machineId, + ); + return `${base}/hosts/${routingKey}${path}?${query.join("&")}`; }, - [routingKey, terminalId, workspaceId], + [host.machineId, host.organizationId, terminalId, workspaceId], ); const handleMessage = useCallback( diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalWebView/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalWebView/index.ts index 4c7ba1b5d35..ca1a8e01e38 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalWebView/index.ts +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalWebView/index.ts @@ -1,6 +1,7 @@ export { type TerminalConnectionState, type TerminalControlMessage, + type TerminalHost, TerminalWebView, type TerminalWebViewHandle, } from "./TerminalWebView"; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/file/FileViewerScreen.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/file/FileViewerScreen.tsx index daf0456e01f..88b4aefb212 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/file/FileViewerScreen.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/file/FileViewerScreen.tsx @@ -6,8 +6,8 @@ import { CodeBlockContent } from "@/components/ai-elements/code-block"; import { Text } from "@/components/ui/text"; import { useWorkspaceHost } from "@/hooks/useWorkspaceHost"; import { - buildRelayHostUrl, getHostServiceClientByUrl, + hostServiceUrl, } from "@/lib/host-service/client"; import type { ChangesetSource } from "../hooks/useWorkspaceChangeset"; import { languageForPath } from "../utils/languageForPath"; @@ -21,7 +21,7 @@ export function FileViewerScreen() { const { host } = useWorkspaceHost(id ?? null); const hostUrl = host?.isOnline === true - ? buildRelayHostUrl(host.organizationId, host.machineId) + ? hostServiceUrl(host.organizationId, host.machineId) : null; const category = (source ?? "unstaged") as ChangesetSource; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/finish-review/FinishReviewSheet.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/finish-review/FinishReviewSheet.tsx index 21e97eba638..59316cb2f85 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/finish-review/FinishReviewSheet.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/finish-review/FinishReviewSheet.tsx @@ -6,8 +6,8 @@ import { Text } from "@/components/ui/text"; import type { HostWorkspaceItem } from "@/hooks/useHostWorkspaces"; import { useWorkspaceHost } from "@/hooks/useWorkspaceHost"; import { - buildRelayHostUrl, getHostServiceClientByUrl, + hostServiceUrl, } from "@/lib/host-service/client"; import { useStartWorkspaceTerminal } from "@/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useStartWorkspaceTerminal"; import { useNewSessionPreferencesStore } from "@/screens/(authenticated)/(home)/home/components/NewChatWidget/stores/newSessionPreferencesStore"; @@ -92,7 +92,7 @@ export function FinishReviewSheet() { router.back(); return; } - const hostUrl = buildRelayHostUrl(host.organizationId, host.machineId); + const hostUrl = hostServiceUrl(host.organizationId, host.machineId); await getHostServiceClientByUrl(hostUrl).terminal.send.mutate({ terminalId: target, workspaceId, diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspaceChangeset/useWorkspaceChangeset.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspaceChangeset/useWorkspaceChangeset.ts index 288ad821711..b6f09d5b8c8 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspaceChangeset/useWorkspaceChangeset.ts +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspaceChangeset/useWorkspaceChangeset.ts @@ -2,9 +2,9 @@ import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { useWorkspaceHost } from "@/hooks/useWorkspaceHost"; import { - buildRelayHostUrl, type GitStatusSnapshot, getHostServiceClientByUrl, + hostServiceUrl, } from "@/lib/host-service/client"; export type ChangesetSource = "against-base" | "staged" | "unstaged"; @@ -46,7 +46,7 @@ export function useWorkspaceChangeset( const { host } = useWorkspaceHost(workspaceId); const hostUrl = host?.isOnline === true - ? buildRelayHostUrl(host.organizationId, host.machineId) + ? hostServiceUrl(host.organizationId, host.machineId) : null; const query = useQuery({ diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspaceCommits/useWorkspaceCommits.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspaceCommits/useWorkspaceCommits.ts index 42dbf7b110b..a1f97da5794 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspaceCommits/useWorkspaceCommits.ts +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspaceCommits/useWorkspaceCommits.ts @@ -1,8 +1,8 @@ import { useQuery } from "@tanstack/react-query"; import { useWorkspaceHost } from "@/hooks/useWorkspaceHost"; import { - buildRelayHostUrl, getHostServiceClientByUrl, + hostServiceUrl, } from "@/lib/host-service/client"; const COMMITS_STALE_MS = 30_000; @@ -16,7 +16,7 @@ export function useWorkspaceCommits(workspaceId: string | null) { const { host } = useWorkspaceHost(workspaceId); const hostUrl = host?.isOnline === true - ? buildRelayHostUrl(host.organizationId, host.machineId) + ? hostServiceUrl(host.organizationId, host.machineId) : null; const query = useQuery({ diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspaceHeaderActions/useWorkspaceHeaderActions.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspaceHeaderActions/useWorkspaceHeaderActions.ts index 0adc4d5208c..8c2be2e3026 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspaceHeaderActions/useWorkspaceHeaderActions.ts +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspaceHeaderActions/useWorkspaceHeaderActions.ts @@ -3,13 +3,15 @@ import { useQueryClient } from "@tanstack/react-query"; import * as Clipboard from "expo-clipboard"; import { useRouter } from "expo-router"; import { Alert, Share } from "react-native"; +import { useCloudWorkspaceActions } from "@/hooks/useCloudWorkspaceActions"; import type { HostWorkspaceRow } from "@/hooks/useHostWorkspaces"; import type { OrgHost } from "@/hooks/useOrgHosts"; import { - buildRelayHostUrl, getHostServiceClientByUrl, + hostServiceUrl, } from "@/lib/host-service/client"; import { isTrpcErrorWithData } from "@/lib/host-service/errors"; +import { isSandboxHost } from "@/lib/sandbox-access"; import { workspaceShareUrl } from "@/lib/web-links"; export function useWorkspaceHeaderActions( @@ -18,6 +20,11 @@ export function useWorkspaceHeaderActions( ) { const router = useRouter(); const queryClient = useQueryClient(); + const cloud = useCloudWorkspaceActions(); + // A sandbox is its own host, keyed by the workspace's id; its name and its + // lifetime belong to the cloud row, not to anything the sandbox serves. + const isCloud = host !== null && isSandboxHost(host.machineId); + const renameWorkspace = async () => { if (!workspace) return; if (!host) { @@ -33,11 +40,15 @@ export function useWorkspaceHeaderActions( const trimmed = name?.trim(); if (!trimmed || trimmed === workspace.name) return; try { - const hostUrl = buildRelayHostUrl(host.organizationId, host.machineId); - await getHostServiceClientByUrl(hostUrl).workspace.update.mutate({ - id: workspace.id, - name: trimmed, - }); + if (isCloud) { + await cloud.rename(workspace.id, trimmed); + } else { + const hostUrl = hostServiceUrl(host.organizationId, host.machineId); + await getHostServiceClientByUrl(hostUrl).workspace.update.mutate({ + id: workspace.id, + name: trimmed, + }); + } } catch { Alert.alert("Rename failed"); } @@ -56,7 +67,7 @@ export function useWorkspaceHeaderActions( skipTeardown: boolean; }) => { if (!workspace || !host) return; - const hostUrl = buildRelayHostUrl(host.organizationId, host.machineId); + const hostUrl = hostServiceUrl(host.organizationId, host.machineId); try { await getHostServiceClientByUrl(hostUrl).workspaceCleanup.destroy.mutate({ workspaceId: workspace.id, @@ -106,6 +117,25 @@ export function useWorkspaceHeaderActions( Alert.alert("Host is not online"); return; } + if (isCloud) { + Alert.alert( + "Delete cloud workspace", + `Delete "${workspace.name}"? This shuts down its sandbox and everything in it.`, + [ + { style: "cancel", text: "Cancel" }, + { + onPress: () => + void cloud + .remove(workspace.id) + .then(() => router.back()) + .catch(() => Alert.alert("Delete failed")), + style: "destructive", + text: "Delete", + }, + ], + ); + return; + } Alert.alert( "Delete workspace", `Delete "${workspace.name}"? This removes its worktree from the host.`, diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspacePullRequestDetail/useWorkspacePullRequestDetail.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspacePullRequestDetail/useWorkspacePullRequestDetail.ts index 8dacac1a123..eb2acb7dd4c 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspacePullRequestDetail/useWorkspacePullRequestDetail.ts +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspacePullRequestDetail/useWorkspacePullRequestDetail.ts @@ -1,8 +1,8 @@ import { useQuery } from "@tanstack/react-query"; import { useWorkspaceHost } from "@/hooks/useWorkspaceHost"; import { - buildRelayHostUrl, getHostServiceClientByUrl, + hostServiceUrl, } from "@/lib/host-service/client"; import type { MergeableState, @@ -39,7 +39,7 @@ export function useWorkspacePullRequestDetail({ const { host } = useWorkspaceHost(workspaceId); const hostUrl = host?.isOnline === true - ? buildRelayHostUrl(host.organizationId, host.machineId) + ? hostServiceUrl(host.organizationId, host.machineId) : null; const ready = hostUrl !== null && owner !== null && repo !== null && pullNumber !== null; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/new-session/NewSessionSheet.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/new-session/NewSessionSheet.tsx index 2ed37cd416c..c522220a48b 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/new-session/NewSessionSheet.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/new-session/NewSessionSheet.tsx @@ -7,8 +7,8 @@ import { Text } from "@/components/ui/text"; import { useTheme } from "@/hooks/useTheme"; import { useWorkspaceHost } from "@/hooks/useWorkspaceHost"; import { - buildRelayHostUrl, getHostServiceClientByUrl, + hostServiceUrl, } from "@/lib/host-service/client"; import { getHostTerminalsQueryKey } from "@/screens/(authenticated)/(home)/home/hooks/useHostTerminals"; import { AgentMark } from "@/screens/(authenticated)/(home)/new-session/agent"; @@ -25,7 +25,7 @@ export function NewSessionSheet() { const queryClient = useQueryClient(); const { workspace, host } = useWorkspaceHost(id ?? null); const hostUrl = host - ? buildRelayHostUrl(host.organizationId, host.machineId) + ? hostServiceUrl(host.organizationId, host.machineId) : null; const presetsQuery = useQuery({ diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/pull-request/[pullRequestId]/hooks/useMergePullRequest/useMergePullRequest.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/pull-request/[pullRequestId]/hooks/useMergePullRequest/useMergePullRequest.ts index 8956273b22a..21dea1ef90b 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/pull-request/[pullRequestId]/hooks/useMergePullRequest/useMergePullRequest.ts +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/pull-request/[pullRequestId]/hooks/useMergePullRequest/useMergePullRequest.ts @@ -2,8 +2,8 @@ import { useMutation } from "@tanstack/react-query"; import { Alert } from "react-native"; import { useWorkspaceHost } from "@/hooks/useWorkspaceHost"; import { - buildRelayHostUrl, getHostServiceClientByUrl, + hostServiceUrl, } from "@/lib/host-service/client"; import type { MergeMethod, @@ -38,7 +38,7 @@ export function useMergePullRequest({ const { host } = useWorkspaceHost(workspaceId); const hostUrl = host?.isOnline === true - ? buildRelayHostUrl(host.organizationId, host.machineId) + ? hostServiceUrl(host.organizationId, host.machineId) : null; const mutation = useMutation({ diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/sessions/SessionsSheet.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/sessions/SessionsSheet.tsx index f986f34eedd..e573c251f07 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/sessions/SessionsSheet.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/sessions/SessionsSheet.tsx @@ -4,8 +4,8 @@ import { useCallback, useMemo } from "react"; import { Alert } from "react-native"; import { useWorkspaceHost } from "@/hooks/useWorkspaceHost"; import { - buildRelayHostUrl, getHostServiceClientByUrl, + hostServiceUrl, } from "@/lib/host-service/client"; import { getHostTerminalsQueryKey, @@ -30,7 +30,7 @@ export function SessionsSheet() { const { workspace, host } = useWorkspaceHost(id ?? null); const { terminalsByWorkspace } = useHostTerminals(host); const hostUrl = host - ? buildRelayHostUrl(host.organizationId, host.machineId) + ? hostServiceUrl(host.organizationId, host.machineId) : null; const savedOrder = useTerminalTabOrderStore((state) => diff --git a/docs/cloud-sandbox-considerations.md b/docs/cloud-sandbox-considerations.md index 65531ed2103..478b346d6c0 100644 --- a/docs/cloud-sandbox-considerations.md +++ b/docs/cloud-sandbox-considerations.md @@ -138,6 +138,40 @@ spinner, disabled in flight — and only failures toast. Kept here as the reasoning, since the same argument applies to any other await we add to this flow. +## Model + +**A cloud workspace is tied to a `v2_projects` row, and that table is already +retired. Open.** #6436 decoupled the app from cloud `v2_projects` and dropped +the FKs that pointed at it "ahead of the table's removal"; nothing writes a row +there any more. Three days later `cloud_workspaces.project_id` landed as a +cascade FK into it, and `create` / provisioning resolve the repo to clone from +that row. So only projects that still have a legacy row can get a cloud +workspace, the desktop picker offers projects the API then rejects, and +dropping the table would cascade-delete every cloud workspace. The mobile port +lists the rows that resolve to a repo through an interim +`cloudWorkspace.listProjects`, fenced as such. + +The row was never the point — provisioning only ever wanted a repo to clone, a +credential to fetch it with, and a display name. What it should hang off is an +**environment**: an org-scoped definition of what a sandbox contains — repos +(0..n, one primary), setup commands, env var names, base image/version, and +later a provider snapshot per version (SUPER-1892). `cloud_workspaces` then +references the environment plus the primary repo's branch. Keep v1 of that +entity to exactly one primary GitHub repo, enforced by validation rather than +schema: host-service assumes one workspace is one git root, and multi-repo or +no-repo sandboxes push into every git-shaped feature (status, diff, PRs, +files-changed) before they can degrade gracefully. + +**Clients must not orchestrate a create.** Creating a cloud workspace is one +API call; the sandbox does the rest on boot (self-seed, fetch, start). Two +follow-ups fall out of holding that line rather than compensating in the app: +the sandbox should launch the agent from the typed prompt itself (today the +prompt only feeds the auto-name and nothing runs it — desktop and mobile both +open to an empty workspace), and attachments for a sandbox belong in blob +storage rather than written to the host, so a create can carry them before the +sandbox exists. Neither should be done by having a client wait for `ready` and +call `agents.run`. + ## Provider **Proxy secret injection depends on a workspace entitlement.** Routing rules send diff --git a/docs/cloud-sandbox-mismatches.md b/docs/cloud-sandbox-mismatches.md index 1d0b0729ca1..e056edac9ad 100644 --- a/docs/cloud-sandbox-mismatches.md +++ b/docs/cloud-sandbox-mismatches.md @@ -61,6 +61,18 @@ allowlist, CORS on the provider's edge (set via the preview's browser and so takes the preview token as a `bl_preview_token` query param. Testing from Node proves nothing about the renderer here. +**The edge sets a cookie, and the desktop's terminal socket depends on it +without saying so.** Any request that presents the preview token — header or +query param — comes back with `Set-Cookie: bl_preview_token=…; HttpOnly; +SameSite=None; Secure; Max-Age=86400`. Only the `/events` dial puts the token +on its URL; the `/terminal/` dial (`useWorkspaceWsUrl`) sends `token=` +and nothing for the edge, and works because Electron replays that cookie on +the upgrade. So terminals on desktop authenticate through a cookie the event +bus happened to earn first. Mobile can't inherit that — its terminal socket +lives in a WKWebView with its own cookie store — so it signs every terminal +dial with `bl_preview_token` explicitly. The desktop should too rather than +rely on ordering. + **The host-service secret does not apply, and a sandbox says so instead of pretending otherwise.** Locally the secret stops anything else on the machine from talking to a host-service bound to loopback; desktop and service share @@ -110,6 +122,19 @@ confirmations no one is there to answer. The image bakes `/root/.claude.json`. Note that a headless `-p` run writes none of those keys, so a smoke test passes while the interactive TUI still blocks. +**Claude refuses its own launch flags under root. Open until the image is +rebuilt.** The builtin agent runs `claude --dangerously-skip-permissions`, and +a sandbox runs as root, so picking Claude in a cloud workspace printed +"--dangerously-skip-permissions cannot be used with root/sudo privileges" and +exited — found from the mobile app, but the desktop launches the same +command. Claude allows the flag under root when `IS_SANDBOX=1` is in its +environment (verified from a sandbox terminal), and then asks once to accept +Bypass Permissions mode, another dialog a headless smoke test never reaches. +host-service now sets `IS_SANDBOX=1` in sandbox-mode PTY env and the image +bakes `bypassPermissionsModeAccepted: true` into `/root/.claude.json`; neither +reaches an existing sandbox, and neither reaches a new one until the image is +rebuilt. + **The checkout is the workspace.** No worktrees, no base repo, no branch creation — anything assuming a worktree can be created or discarded next to a main checkout has nothing to work with. diff --git a/packages/host-service/src/terminal/env.ts b/packages/host-service/src/terminal/env.ts index d8428aafb3e..03edb6dfe53 100644 --- a/packages/host-service/src/terminal/env.ts +++ b/packages/host-service/src/terminal/env.ts @@ -280,6 +280,11 @@ export function buildV2TerminalEnv( const value = process.env[key]; if (value) env[key] = value; } + // The sandbox runs as root, and Claude refuses + // `--dangerously-skip-permissions` under root unless told it is inside a + // sandbox — which is exactly what this is. Without it the builtin Claude + // agent exits on launch with "cannot be used with root/sudo privileges". + env.IS_SANDBOX = "1"; } // Electron child processes can't access macOS Keychain for TLS cert verification, diff --git a/packages/trpc/src/lib/blaxel/blaxel.ts b/packages/trpc/src/lib/blaxel/blaxel.ts index c629fb13371..bed80141838 100644 --- a/packages/trpc/src/lib/blaxel/blaxel.ts +++ b/packages/trpc/src/lib/blaxel/blaxel.ts @@ -178,7 +178,25 @@ export async function deleteSandbox(providerSandboxId: string): Promise { try { await SandboxInstance.delete(providerSandboxId); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!/not found|404/i.test(message)) throw error; + if (!isSandboxNotFound(error)) throw error; } } + +/** + * The SDK's not-found error carries the status on the object, not in the + * message — its `message` is empty — so a text match alone lets a workspace + * whose sandbox never came up (a failed provision, or one already torn down) + * refuse deletion forever. + */ +function isSandboxNotFound(error: unknown): boolean { + if (typeof error === "object" && error !== null) { + const { code, error: reason } = error as { + code?: unknown; + error?: unknown; + }; + if (code === 404) return true; + if (typeof reason === "string" && /not found/i.test(reason)) return true; + } + const message = error instanceof Error ? error.message : String(error); + return /not found|404/i.test(message); +} diff --git a/scripts/sandbox/image.ts b/scripts/sandbox/image.ts index e9b79bc7a7a..bd0fadcf395 100644 --- a/scripts/sandbox/image.ts +++ b/scripts/sandbox/image.ts @@ -124,9 +124,13 @@ export const sandboxImage = ImageInstance.fromRegistry("node:24-bookworm-slim") // you answer them; `-p` runs never write them, which is why the prompts // survive a headless smoke test. `customApiKeyResponses` matches on the // key's last 20 characters, so it stays valid as long as the placeholder does. + // The builtin agent launches `claude --dangerously-skip-permissions`, which + // opens a fourth dialog — accept Bypass Permissions mode — that headless + // runs never reach either; this is the key that answers it. .runCommands( `printf '%s' '${JSON.stringify({ hasCompletedOnboarding: true, + bypassPermissionsModeAccepted: true, theme: "dark", customApiKeyResponses: { approved: [SANDBOX_CREDENTIAL_PLACEHOLDER.slice(-20)],