Skip to content
Merged
1 change: 1 addition & 0 deletions apps/mobile/hooks/useCloudWorkspaceActions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useCloudWorkspaceActions } from "./useCloudWorkspaceActions";
Original file line number Diff line number Diff line change
@@ -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 };
}
7 changes: 7 additions & 0 deletions apps/mobile/hooks/useCloudWorkspaceItems/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export {
type CloudWorkspaceItem,
type CloudWorkspaceItemsValue,
type CloudWorkspaceStatus,
sandboxWorkspacesQuery,
useCloudWorkspaceItems,
} from "./useCloudWorkspaceItems";
178 changes: 178 additions & 0 deletions apps/mobile/hooks/useCloudWorkspaceItems/useCloudWorkspaceItems.ts
Original file line number Diff line number Diff line change
@@ -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<HostWorkspaceRow[]> => {
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<CloudWorkspaceItem[]>(() => {
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<HostWorkspacesCacheOps>(() => {
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<HostWorkspaceRow[] | undefined>(
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<HostWorkspaceRow[] | undefined>(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<HostWorkspaceRow[]>(key);
},
};
}, [queryClient]);

return {
items,
targets,
cache,
isReady: listReady && accessReady,
};
}
6 changes: 6 additions & 0 deletions apps/mobile/hooks/useCloudWorkspaces/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export {
type CloudWorkspaceRow,
type CloudWorkspacesValue,
getCloudWorkspacesQueryKey,
useCloudWorkspaces,
} from "./useCloudWorkspaces";
101 changes: 101 additions & 0 deletions apps/mobile/hooks/useCloudWorkspaces/useCloudWorkspaces.ts
Original file line number Diff line number Diff line change
@@ -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<CloudWorkspaceRow[]> => {
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,
};
}
4 changes: 2 additions & 2 deletions apps/mobile/hooks/useHostProjects/useHostProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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({
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/hooks/useHostWorkspaces/useHostWorkspaces.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/hooks/useSandboxAccess/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
type SandboxAccessValue,
type SandboxTarget,
useSandboxAccess,
} from "./useSandboxAccess";
Loading
Loading