diff --git a/apps/desktop/src/lib/trpc/routers/migration/index.ts b/apps/desktop/src/lib/trpc/routers/migration/index.ts index f7d3bd2643b..b0f63bbceec 100644 --- a/apps/desktop/src/lib/trpc/routers/migration/index.ts +++ b/apps/desktop/src/lib/trpc/routers/migration/index.ts @@ -1,30 +1,14 @@ -import { - projects, - v1MigrationState, - workspaceSections, - workspaces, - worktrees, -} from "@superset/local-db"; -import { eq, isNotNull, isNull } from "drizzle-orm"; +import { projects, workspaces, worktrees } from "@superset/local-db"; +import { isNotNull, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; -import { z } from "zod"; import { publicProcedure, router } from "../.."; -const migrationStateRowSchema = z.object({ - v1Id: z.string().min(1), - kind: z.enum(["project", "workspace"]), - v2Id: z.string().nullable(), - organizationId: z.string().min(1), - status: z.enum(["success", "linked", "error", "skipped"]), - reason: z.string().nullable().optional(), -}); - export const createMigrationRouter = () => { return router({ readV1Projects: publicProcedure.query(() => { - // Only migrate pinned projects. v1's `hideProject` nulls tab_order when - // the last workspace in a project is deleted, effectively abandoning the - // project — don't resurrect those in v2. + // Only surface pinned projects. v1's `hideProject` nulls tab_order + // when the last workspace in a project is deleted, effectively + // abandoning the project — don't resurrect those in v2. return localDb .select() .from(projects) @@ -43,58 +27,6 @@ export const createMigrationRouter = () => { readV1Worktrees: publicProcedure.query(() => { return localDb.select().from(worktrees).all(); }), - - readV1WorkspaceSections: publicProcedure.query(() => { - return localDb.select().from(workspaceSections).all(); - }), - - listState: publicProcedure - .input(z.object({ organizationId: z.string().min(1) })) - .query(({ input }) => { - return localDb - .select() - .from(v1MigrationState) - .where(eq(v1MigrationState.organizationId, input.organizationId)) - .all(); - }), - - upsertState: publicProcedure - .input(migrationStateRowSchema) - .mutation(({ input }) => { - localDb - .insert(v1MigrationState) - .values({ - v1Id: input.v1Id, - kind: input.kind, - v2Id: input.v2Id, - organizationId: input.organizationId, - status: input.status, - reason: input.reason ?? null, - migratedAt: Date.now(), - }) - .onConflictDoUpdate({ - target: [ - v1MigrationState.organizationId, - v1MigrationState.v1Id, - v1MigrationState.kind, - ], - set: { - v2Id: input.v2Id, - status: input.status, - reason: input.reason ?? null, - migratedAt: Date.now(), - }, - }) - .run(); - }), - - clearState: publicProcedure - .input(z.object({ organizationId: z.string().min(1) })) - .mutation(({ input }) => { - localDb - .delete(v1MigrationState) - .where(eq(v1MigrationState.organizationId, input.organizationId)) - .run(); - }), }); }; +// CI rerun trigger diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index ae33fbaf364..96292f17011 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -5,6 +5,7 @@ import * as fs from "node:fs"; import path from "node:path"; import { settings } from "@superset/local-db"; import { getHostId, getHostName } from "@superset/shared/host-info"; +import { MIN_HOST_SERVICE_VERSION } from "@superset/shared/host-version"; import { app } from "electron"; import { env } from "main/env.main"; import semver from "semver"; @@ -30,19 +31,6 @@ import { localDb } from "./local-db"; import { killPersistentScope, spawnPersistent } from "./process-persistence"; import { HOOK_PROTOCOL_VERSION } from "./terminal/env"; -/** - * Minimum host-service version this app can work with. Bumping this forces - * the coordinator to kill + respawn any adopted service older than this, - * which is how we prevent the renderer from talking to a stale host-service - * that's missing newly-added procedures/params. - * - * 0.3.0: host-service registers via cloud `host.ensure` (was - * `device.ensureV2Host`); v2_hosts/v2_users_hosts/v2_workspaces use - * machineId text instead of uuid surrogates. - * 0.2.0: `workspaceCreation.adopt` gained optional `worktreePath`. - */ -const MIN_HOST_SERVICE_VERSION = "0.3.0"; - export type HostServiceStatus = "starting" | "running" | "stopped"; export interface Connection { diff --git a/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts b/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts index 19742f6c014..5f3088e8099 100644 --- a/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts +++ b/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts @@ -7,11 +7,15 @@ const IS_DEV = process.env.NODE_ENV === "development"; /** * Returns effective v2 state: remote PostHog flag AND local opt-in. * Also returns the raw remote flag so the toggle can be shown conditionally. + * + * FORK NOTE: keeps the upstream-style `optInV2 === true` strict check + * (since `optInV2` is now nullable per #4176) but preserves the fork's + * object-shape return value with the extra `isRemoteV2Enabled` flag. */ export function useIsV2CloudEnabled() { const remoteV2Enabled = useFeatureFlagEnabled(FEATURE_FLAGS.V2_CLOUD) ?? false; - const optInV2 = useV2LocalOverrideStore((s) => s.optInV2); + const optInV2 = useV2LocalOverrideStore((s) => s.optInV2 === true); if (IS_DEV) { return { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index 4cd977715e4..050307e86c2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -16,7 +16,6 @@ import { useHotkey } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { DashboardSidebar } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar"; import { useDevSeedV2Sidebar } from "renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar"; -import { useMigrateV1DataToV2 } from "renderer/routes/_authenticated/hooks/useMigrateV1DataToV2"; import { ResizablePanel } from "renderer/screens/main/components/ResizablePanel"; import { WorkspaceSidebar } from "renderer/screens/main/components/WorkspaceSidebar"; import { DeleteWorkspaceDialog } from "renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components"; @@ -41,7 +40,6 @@ function DashboardLayout() { const openNewWorkspaceModal = useOpenNewWorkspaceModal(); const { isV2CloudEnabled } = useIsV2CloudEnabled(); useDevSeedV2Sidebar(); - useMigrateV1DataToV2(); // Get current workspace from route to pre-select project in new workspace modal const matchRoute = useMatchRoute(); const currentWorkspaceMatch = matchRoute({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx index 4f6a763070c..c6a8c8d10ed 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx @@ -17,7 +17,6 @@ import { } from "renderer/assets/app-icons/preset-icons"; import { HotkeyMenuShortcut } from "renderer/components/HotkeyMenuShortcut"; import type { HotkeyId } from "renderer/hotkeys"; -import { useMigrateV1PresetsToV2 } from "renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; import { V2PresetBarItem } from "./components/V2PresetBarItem"; @@ -68,7 +67,6 @@ export function V2PresetsBar({ const navigate = useNavigate(); const isDark = useIsDarkTheme(); const collections = useCollections(); - useMigrateV1PresetsToV2(); const [localVisiblePresetIds, setLocalVisiblePresetIds] = useState( () => getVisiblePresetOrder(matchedPresets), diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostIncompatibleState/WorkspaceHostIncompatibleState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostIncompatibleState/WorkspaceHostIncompatibleState.tsx new file mode 100644 index 00000000000..150561b5cf8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostIncompatibleState/WorkspaceHostIncompatibleState.tsx @@ -0,0 +1,97 @@ +import { Button } from "@superset/ui/button"; +import { Link } from "@tanstack/react-router"; +import { ArrowRight, ArrowUpCircle, Monitor } from "lucide-react"; + +interface WorkspaceHostIncompatibleStateProps { + hostName: string; + hostVersion: string; + minVersion: string; +} + +export function WorkspaceHostIncompatibleState({ + hostName, + hostVersion, + minVersion, +}: WorkspaceHostIncompatibleStateProps) { + return ( +
+
+
+
+
+ +
+ +
+

+ Host needs an update +

+

+ This workspace's host is on an older version of Superset than this + client supports. Update the Superset app on that device to + reconnect. +

+
+ +
+
+
+
+
+ + Running + + + {hostVersion} + +
+
+ + Required + + + ≥ {minVersion} + +
+
+
+ + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostIncompatibleState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostIncompatibleState/index.ts new file mode 100644 index 00000000000..79dbdec80a9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostIncompatibleState/index.ts @@ -0,0 +1 @@ +export { WorkspaceHostIncompatibleState } from "./WorkspaceHostIncompatibleState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostOfflineState/WorkspaceHostOfflineState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostOfflineState/WorkspaceHostOfflineState.tsx new file mode 100644 index 00000000000..19a8faf60e2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostOfflineState/WorkspaceHostOfflineState.tsx @@ -0,0 +1,73 @@ +import { Button } from "@superset/ui/button"; +import { Link } from "@tanstack/react-router"; +import { ArrowRight, Monitor } from "lucide-react"; + +interface WorkspaceHostOfflineStateProps { + hostName: string; +} + +export function WorkspaceHostOfflineState({ + hostName, +}: WorkspaceHostOfflineStateProps) { + return ( +
+
+
+
+
+
+ +
+

+ Host is offline +

+

+ This workspace lives on a device that isn't reachable right now. + Open Superset on that device to bring the workspace back online. +

+
+ +
+
+ + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostOfflineState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostOfflineState/index.ts new file mode 100644 index 00000000000..b0e77016c34 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostOfflineState/index.ts @@ -0,0 +1 @@ +export { WorkspaceHostOfflineState } from "./WorkspaceHostOfflineState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/index.ts new file mode 100644 index 00000000000..e8a304923fa --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/index.ts @@ -0,0 +1,4 @@ +export { + type RemoteHostStatus, + useRemoteHostStatus, +} from "./useRemoteHostStatus"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/useRemoteHostStatus.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/useRemoteHostStatus.ts new file mode 100644 index 00000000000..e0d03c6721b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/useRemoteHostStatus.ts @@ -0,0 +1,101 @@ +import type { SelectV2Workspace } from "@superset/db/schema"; +import { buildHostRoutingKey } from "@superset/shared/host-routing"; +import { MIN_HOST_SERVICE_VERSION } from "@superset/shared/host-version"; +import { and, eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useQuery } from "@tanstack/react-query"; +import { env } from "renderer/env.renderer"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import semver from "semver"; + +export type RemoteHostStatus = + | { status: "skip" } + | { status: "loading" } + | { status: "offline"; hostName: string } + | { + status: "incompatible"; + hostName: string; + hostVersion: string; + minVersion: string; + } + | { status: "ready" }; + +const HOST_INFO_STALE_MS = 30_000; + +export function useRemoteHostStatus( + workspace: Pick | null, +): RemoteHostStatus { + const collections = useCollections(); + const { machineId } = useLocalHostService(); + const organizationId = workspace?.organizationId ?? ""; + const hostId = workspace?.hostId ?? ""; + const isLocal = + workspace != null && machineId != null && workspace.hostId === machineId; + const filterMachineId = !workspace || isLocal ? "" : hostId; + + const { data: hostRows = [], isReady } = useLiveQuery( + (q) => + q + .from({ hosts: collections.v2Hosts }) + .where(({ hosts }) => + and( + eq(hosts.organizationId, organizationId), + eq(hosts.machineId, filterMachineId), + ), + ) + .select(({ hosts }) => ({ + name: hosts.name, + isOnline: hosts.isOnline, + })), + [collections, organizationId, filterMachineId], + ); + const hostRow = hostRows[0] ?? null; + + const hostUrl = `${env.RELAY_URL}/hosts/${buildHostRoutingKey( + organizationId, + hostId, + )}`; + + const infoQuery = useQuery({ + queryKey: ["remoteHostInfo", organizationId, hostId], + queryFn: () => getHostServiceClientByUrl(hostUrl).host.info.query(), + enabled: workspace != null && !isLocal && hostRow?.isOnline === true, + staleTime: HOST_INFO_STALE_MS, + retry: false, + }); + + if (!workspace) return { status: "loading" }; + if (isLocal) return { status: "skip" }; + if (!isReady) return { status: "loading" }; + // No matching v2Hosts row once the collection is ready — host was + // deregistered while the workspace record stuck around. Surface the + // offline screen so users have a recovery path instead of a blank div. + if (!hostRow) return { status: "offline", hostName: "Unknown host" }; + + if (!hostRow.isOnline) { + return { status: "offline", hostName: hostRow.name }; + } + + if (infoQuery.isPending) return { status: "loading" }; + + if (infoQuery.isError) { + // Cloud reports the host online but the relay round-trip failed — + // treat as offline; the most common cause is a stale `isOnline` + // flag after the host crashed without a clean disconnect. + return { status: "offline", hostName: hostRow.name }; + } + + const hostVersion = infoQuery.data.version; + if (!semver.satisfies(hostVersion, `>=${MIN_HOST_SERVICE_VERSION}`)) { + return { + status: "incompatible", + hostName: hostRow.name, + hostVersion, + minVersion: MIN_HOST_SERVICE_VERSION, + }; + } + + return { status: "ready" }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx index 39c0cdf92cc..3aade013756 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx @@ -11,6 +11,10 @@ import { import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { useWorkspaceCreatesStore } from "renderer/stores/workspace-creates"; +import { WorkspaceHostIncompatibleState } from "./components/WorkspaceHostIncompatibleState"; +import { WorkspaceHostOfflineState } from "./components/WorkspaceHostOfflineState"; +import { useRemoteHostStatus } from "./hooks/useRemoteHostStatus"; import { WorkspaceTrpcProvider } from "./providers/WorkspaceTrpcProvider"; export const Route = createFileRoute("/_authenticated/_dashboard/v2-workspace")( @@ -44,7 +48,16 @@ function V2WorkspaceLayout() { })), [collections, workspaceId], ); - const workspace = workspaces[0] ?? null; + const syncedWorkspace = workspaces?.[0] ?? null; + const inFlight = useWorkspaceCreatesStore((store) => + workspaceId + ? store.entries.find((entry) => entry.snapshot.id === workspaceId) + : undefined, + ); + // Fall back to the cloud row cached on the in-flight entry while + // Electric hasn't yet delivered the synced row. The cloud has already + // confirmed the workspace at this point — no need to block on sync. + const workspace = syncedWorkspace ?? inFlight?.cloudRow ?? null; const isLocal = workspace?.hostId === machineId; const hostUrl = !workspace @@ -62,6 +75,8 @@ function V2WorkspaceLayout() { ensureWorkspaceInSidebar(workspace.id, workspace.projectId); }, [ensureWorkspaceInSidebar, workspace]); + const hostStatus = useRemoteHostStatus(workspace); + if (!workspaceId || !isReady) { return null; } @@ -70,6 +85,22 @@ function V2WorkspaceLayout() { return ; } + if (hostStatus.status === "offline") { + return ; + } + if (hostStatus.status === "incompatible") { + return ( + + ); + } + if (hostStatus.status === "loading") { + return
; + } + return ( - import("@paper-design/shaders-react").then((mod) => ({ - default: mod.Dithering, - })), -); - -type MigrationPage = "welcome" | "results"; -type ProjectStatus = "created" | "linked" | "synced" | "error"; -type WorkspaceStatus = "adopted" | "synced" | "skipped" | "error"; - -interface ProjectEntry { - name: string; - status: ProjectStatus; - reason?: string; -} - -interface WorkspaceEntry { - name: string; - branch: string; - status: WorkspaceStatus; - reason?: string; -} - -interface MigrationSummary { - projectsCreated: number; - projectsLinked: number; - projectsErrored: number; - workspacesCreated: number; - workspacesSkipped: number; - workspacesErrored: number; - projects: ProjectEntry[]; - workspaces: WorkspaceEntry[]; - errors: Array<{ kind: string; name: string; message: string }>; -} - -interface StoredEntry { - summary: MigrationSummary; - createdAt: number; -} - -interface ModalUiState { - page: MigrationPage; - isTransitioning: boolean; - expandedSection: "projects" | "workspaces" | "errors" | null; -} - -const INITIAL_MODAL_UI_STATE: ModalUiState = { - page: "welcome", - isTransitioning: false, - expandedSection: null, -}; - -const GRADIENT_COLORS = [ - "#f97316", - "#fb923c", - "#f59e0b", - "#431407", -] as const satisfies readonly [string, string, string, string]; - -function summaryKey(organizationId: string): string { - return `v1-migration-summary-${organizationId}`; -} - -function readSummary(organizationId: string): MigrationSummary | null { - const raw = localStorage.getItem(summaryKey(organizationId)); - if (!raw) return null; - try { - const parsed = JSON.parse(raw) as StoredEntry; - return parsed.summary ?? null; - } catch { - localStorage.removeItem(summaryKey(organizationId)); - return null; - } -} - -export function V1MigrationSummaryModal() { - const { data: session } = authClient.useSession(); - const organizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); - const [summary, setSummary] = useState(null); - const [modalUiState, setModalUiState] = useState( - INITIAL_MODAL_UI_STATE, - ); - const { page, isTransitioning, expandedSection } = modalUiState; - - useEffect(() => { - if (!organizationId) { - setSummary(null); - setModalUiState(INITIAL_MODAL_UI_STATE); - return; - } - setSummary(readSummary(organizationId)); - - const onUpdate = (event: Event) => { - const detail = (event as CustomEvent<{ organizationId: string }>).detail; - if (detail?.organizationId === organizationId) { - setSummary(readSummary(organizationId)); - setModalUiState(INITIAL_MODAL_UI_STATE); - } - }; - window.addEventListener(V1_MIGRATION_SUMMARY_EVENT, onUpdate); - - return () => { - window.removeEventListener(V1_MIGRATION_SUMMARY_EVENT, onUpdate); - }; - }, [organizationId]); - - const dismiss = () => { - if (organizationId) localStorage.removeItem(summaryKey(organizationId)); - setSummary(null); - setModalUiState(INITIAL_MODAL_UI_STATE); - }; - - const transitionToPage = (nextPage: MigrationPage) => { - if (page === nextPage || isTransitioning) return; - setModalUiState((current) => ({ ...current, isTransitioning: true })); - window.setTimeout(() => { - setModalUiState((current) => ({ - ...current, - page: nextPage, - isTransitioning: false, - })); - }, 160); - }; - - const toggleSection = (section: "projects" | "workspaces" | "errors") => { - setModalUiState((current) => ({ - ...current, - expandedSection: current.expandedSection === section ? null : section, - })); - }; - - if (!summary) return null; - - return ( - - event.preventDefault()} - onPointerDownOutside={(event) => event.preventDefault()} - onInteractOutside={(event) => event.preventDefault()} - > - - {page === "welcome" - ? "Welcome to Superset v2" - : "V1 migration results"} - - - Review the migration summary and click Done to continue. - - -
- {page === "welcome" ? ( - - ) : ( - - )} -
- -
- {page === "results" ? ( - - ) : ( -
- )} - -
- -
- ); -} - -function WelcomePage() { - return ( -
- -
-
-
- Welcome to Superset v2 -
-
-
- ); -} - -interface DitheredBackgroundProps { - colors: readonly [string, string, string, string]; - className?: string; -} - -function DitheredBackground({ - colors, - className = "", -}: DitheredBackgroundProps) { - return ( -
- - - -
- ); -} - -interface ResultsPageProps { - summary: MigrationSummary; - expandedSection: "projects" | "workspaces" | "errors" | null; - onToggleSection: (section: "projects" | "workspaces" | "errors") => void; -} - -function ResultsPage({ - summary, - expandedSection, - onToggleSection, -}: ResultsPageProps) { - const copyText = electronTrpc.external.copyText.useMutation(); - const [isSendingSupportReport, setIsSendingSupportReport] = useState(false); - const projectsTotal = summary.projects.filter( - (project) => project.status !== "error", - ).length; - const workspacesTotal = summary.workspaces.filter( - (workspace) => workspace.status !== "error", - ).length; - const hasErrors = summary.errors.length > 0; - const projectDetail = [ - countByStatus(summary.projects, "synced", "synced"), - countByStatus(summary.projects, "linked", "linked"), - countByStatus(summary.projects, "created", "created"), - ] - .filter(Boolean) - .join(" · "); - const workspaceDetail = [ - countByStatus(summary.workspaces, "synced", "synced"), - countByStatus(summary.workspaces, "adopted", "adopted"), - countByStatus(summary.workspaces, "skipped", "skipped"), - ] - .filter(Boolean) - .join(" · "); - const contactSupport = async () => { - const report = buildMigrationSupportReport(summary); - setIsSendingSupportReport(true); - try { - await apiTrpcClient.support.sendMigrationReport.mutate({ report }); - toast.success("Migration details sent to support"); - } catch (error) { - console.warn("[v1-migration] Failed to send support report:", error); - try { - await copyText.mutateAsync(report); - toast.success("Migration details copied to clipboard"); - } catch (copyError) { - console.warn( - "[v1-migration] Failed to copy support report:", - copyError, - ); - toast.error("Could not send migration details"); - } - } finally { - setIsSendingSupportReport(false); - } - }; - - return ( -
-
-
- Migration results -
-

- Ran into issues?{" "} - - . -

-
- -
- 0 - ? () => onToggleSection("projects") - : undefined - } - > - - {summary.projects.map((p) => ( - - ))} - - - 0 - ? () => onToggleSection("workspaces") - : undefined - } - > - - {summary.workspaces.map((w) => ( - - ))} - - - {hasErrors && ( - onToggleSection("errors")} - > - - {summary.errors.map((error) => ( - - ))} - - - )} -
-
- ); -} - -function entryTone( - status: ProjectStatus | WorkspaceStatus, -): "success" | "muted" | "error" { - if (status === "error") return "error"; - if (status === "skipped") return "muted"; - return "success"; -} - -function countByStatus( - entries: T[], - status: T["status"], - label: string, -): string | null { - const count = entries.filter((entry) => entry.status === status).length; - if (count === 0) return null; - return `${count} ${label}`; -} - -function buildMigrationSupportReport(summary: MigrationSummary): string { - const lines = [ - "Hi Superset team,", - "", - "I ran into an issue with the V1 to V2 migration.", - "", - "Migration summary:", - `- Projects: ${summary.projectsCreated} created, ${summary.projectsLinked} linked, ${summary.projectsErrored} errored`, - `- Workspaces: ${summary.workspacesCreated} created, ${summary.workspacesSkipped} skipped, ${summary.workspacesErrored} errored`, - ]; - - const relevantEntries = [ - ...summary.errors.map( - (error) => `${error.kind}: ${error.name} - ${error.message}`, - ), - ...summary.workspaces - .filter((workspace) => workspace.status === "skipped") - .map( - (workspace) => - `workspace: ${workspace.name} (${workspace.branch}) - ${workspace.reason ?? workspace.status}`, - ), - ]; - - if (relevantEntries.length > 0) { - lines.push( - "", - "Migration errors and skipped items:", - ...relevantEntries - .slice(0, 20) - .map((entry) => `- ${truncateSupportLine(entry)}`), - ); - if (relevantEntries.length > 20) { - lines.push(`- ${relevantEntries.length - 20} more item(s) not included`); - } - } - - return lines.join("\n"); -} - -function truncateSupportLine(value: string): string { - if (value.length <= 240) return value; - return `${value.slice(0, 237)}...`; -} - -interface SummaryRowProps { - icon: React.ComponentType<{ className?: string; strokeWidth?: number }>; - label: string; - count: number; - detail?: string; - variant?: "default" | "error"; -} - -interface ExpandableSummaryRowProps extends SummaryRowProps { - expanded: boolean; - onToggle?: () => void; - children: React.ReactNode; -} - -function ExpandableSummaryRow({ - icon: Icon, - label, - count, - detail, - variant = "default", - expanded, - onToggle, - children, -}: ExpandableSummaryRowProps) { - const clickable = onToggle !== undefined; - return ( -
- - {expanded &&
{children}
} -
- ); -} - -function EntryList({ children }: { children: React.ReactNode }) { - return
{children}
; -} - -interface EntryProps { - primary: string; - secondary?: string; - statusLabel: string; - statusTone: "success" | "muted" | "error"; - detail?: string; -} - -function Entry({ - primary, - secondary, - statusLabel, - statusTone, - detail, -}: EntryProps) { - return ( -
-
- - {primary} - - {secondary && ( - - {secondary} - - )} - {detail && ( - - {detail} - - )} -
- - {statusLabel} - -
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V1MigrationSummaryModal/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V1MigrationSummaryModal/index.ts deleted file mode 100644 index 6e5171b5f5e..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V1MigrationSummaryModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { V1MigrationSummaryModal } from "./V1MigrationSummaryModal"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts index 108ea0afb1f..829d82a06ba 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts @@ -8,26 +8,13 @@ import { } from "renderer/routes/_authenticated/components/utils/paneLifecycleRows"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { AppCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; -import { isSidebarWorkspaceVisible } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; +import { + getNextTabOrder, + getPrependTabOrder, + isSidebarWorkspaceVisible, +} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; import { PROJECT_CUSTOM_COLORS } from "shared/constants/project-colors"; -function getNextTabOrder(items: Array<{ tabOrder: number }>): number { - const maxTabOrder = items.reduce( - (maxValue, item) => Math.max(maxValue, item.tabOrder), - 0, - ); - return maxTabOrder + 1; -} - -function getPrependTabOrder(items: Array<{ tabOrder: number }>): number { - if (items.length === 0) return 1; - const minTabOrder = items.reduce( - (minValue, item) => Math.min(minValue, item.tabOrder), - Number.POSITIVE_INFINITY, - ); - return minTabOrder - 1; -} - type ProjectTopLevelItem = { type: "workspace" | "section"; id: string; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/index.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/index.ts deleted file mode 100644 index c3575f84834..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - readLastMigrationRunAt, - useMigrateV1DataToV2, - V1_MIGRATION_LAST_RUN_AT_EVENT, - V1_MIGRATION_SUMMARY_EVENT, -} from "./useMigrateV1DataToV2"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts deleted file mode 100644 index 2c8eba026a2..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts +++ /dev/null @@ -1,1110 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { HostServiceClient } from "renderer/lib/host-service-client"; -import type { electronTrpcClient } from "renderer/lib/trpc-client"; -import type { OrgCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; -import { migrateV1DataToV2 } from "./migrate"; - -type ElectronTrpcClient = typeof electronTrpcClient; - -interface V1ProjectRow { - id: string; - name: string; - mainRepoPath: string; - tabOrder: number | null; - defaultApp: string | null; -} - -interface V1WorkspaceRow { - id: string; - projectId: string; - worktreeId: string | null; - type: "branch" | "worktree"; - branch: string; - name: string; - sectionId: string | null; - tabOrder: number; -} - -interface V1WorktreeRow { - id: string; - path: string; - baseBranch?: string | null; -} - -interface V1SectionRow { - id: string; - projectId: string; - name: string; - tabOrder: number; - isCollapsed: boolean | null; - color: string | null; -} - -interface StateRow { - v1Id: string; - v2Id: string | null; - organizationId: string; - kind: "project" | "workspace"; - status: "success" | "linked" | "error" | "skipped"; - reason: string | null; -} - -type PathResponse = - | { candidates: Array<{ id: string }> } - | { err: "path-missing" }; - -interface FakeEnv { - v1Projects: V1ProjectRow[]; - v1Workspaces: V1WorkspaceRow[]; - v1Worktrees: V1WorktreeRow[]; - v1Sections: V1SectionRow[]; - state: Map; - findByPath: Map; - failNextStateWriteFor: Set; - hostProjectsByPath: Map; - hostWorkspacesByKey: Map; - setupThrowsFor: Set; - createThrowsFor: Set; - adoptThrowsFor: Map; - adoptThrowsForPath: Map; - createCalls: Array<{ name: string; repoPath: string }>; - createdProjectIds: string[]; - setupCalls: Array<{ projectId: string; repoPath?: string }>; - adoptCalls: Array<{ - projectId: string; - branch: string; - worktreePath?: string; - baseBranch?: string; - existingWorkspaceId?: string; - }>; - createdWorkspaceIds: string[]; -} - -function makeFakeEnv(overrides: Partial = {}): FakeEnv { - return { - v1Projects: [], - v1Workspaces: [], - v1Worktrees: [], - v1Sections: [], - state: new Map(), - findByPath: new Map(), - failNextStateWriteFor: new Set(), - hostProjectsByPath: new Map(), - hostWorkspacesByKey: new Map(), - setupThrowsFor: new Set(), - createThrowsFor: new Set(), - adoptThrowsFor: new Map(), - adoptThrowsForPath: new Map(), - createCalls: [], - createdProjectIds: [], - setupCalls: [], - adoptCalls: [], - createdWorkspaceIds: [], - ...overrides, - }; -} - -function trpcErr(code: string, message = code) { - return Object.assign(new Error(message), { data: { code } }); -} - -function makeElectronTrpc(env: FakeEnv): ElectronTrpcClient { - const createdV2s: string[] = []; - const hostProjects = new Set(); - const hostWorkspaces = new Set(); - void createdV2s; - void hostProjects; - void hostWorkspaces; - - const stub = { - migration: { - readV1Projects: { query: async () => env.v1Projects }, - readV1Workspaces: { query: async () => env.v1Workspaces }, - readV1Worktrees: { query: async () => env.v1Worktrees }, - readV1WorkspaceSections: { query: async () => env.v1Sections }, - listState: { - query: async ({ organizationId }: { organizationId: string }) => - Array.from(env.state.values()).filter( - (r) => r.organizationId === organizationId, - ), - }, - upsertState: { - mutate: async (row: Omit) => { - const key = `${row.kind}:${row.v1Id}`; - if (env.failNextStateWriteFor.delete(key)) { - throw new Error(`failed to write migration state for ${key}`); - } - env.state.set(key, { - v1Id: row.v1Id, - v2Id: row.v2Id, - organizationId: row.organizationId, - kind: row.kind, - status: row.status, - reason: row.reason ?? null, - }); - }, - }, - }, - }; - return stub as unknown as ElectronTrpcClient; -} - -function makeHostService(env: FakeEnv): HostServiceClient { - const idCounter = { n: 0 }; - const nextId = (prefix: string) => `${prefix}-${++idCounter.n}`; - - const stub = { - project: { - findByPath: { - query: async ({ repoPath }: { repoPath: string }) => { - const result = env.findByPath.get(repoPath); - if (result) { - if ("err" in result) { - throw new Error(`path not a git repo: ${repoPath}`); - } - return result; - } - const existingId = env.hostProjectsByPath.get(repoPath); - if (existingId) return { candidates: [{ id: existingId }] }; - return { candidates: [] }; - }, - }, - setup: { - mutate: async ({ - projectId, - mode, - }: { - projectId: string; - mode: { repoPath: string; allowRelocate?: boolean }; - }) => { - env.setupCalls.push({ projectId, repoPath: mode.repoPath }); - if (env.setupThrowsFor.has(projectId)) { - throw trpcErr("CONFLICT", "already set up elsewhere"); - } - env.hostProjectsByPath.set(mode.repoPath, projectId); - return { repoPath: "/fake" }; - }, - }, - create: { - mutate: async ({ - name, - mode, - }: { - name: string; - mode: { repoPath: string }; - }) => { - if (env.createThrowsFor.has(name)) { - throw new Error(`cloud create failed for ${name}`); - } - env.createCalls.push({ name, repoPath: mode.repoPath }); - const projectId = nextId("v2-proj"); - env.createdProjectIds.push(projectId); - env.hostProjectsByPath.set(mode.repoPath, projectId); - return { projectId, repoPath: mode.repoPath }; - }, - }, - }, - workspace: { - get: { - query: async ({ id }: { id: string }) => { - if (![...env.hostWorkspacesByKey.values()].includes(id)) { - throw trpcErr("NOT_FOUND", "Workspace not found"); - } - return { id }; - }, - }, - }, - workspaceCreation: { - adopt: { - mutate: async ({ - projectId, - branch, - worktreePath, - baseBranch, - existingWorkspaceId, - }: { - projectId: string; - branch: string; - worktreePath?: string; - baseBranch?: string; - existingWorkspaceId?: string; - }) => { - const call = { - projectId, - branch, - worktreePath, - baseBranch, - } as (typeof env.adoptCalls)[number]; - if (existingWorkspaceId) - call.existingWorkspaceId = existingWorkspaceId; - env.adoptCalls.push(call); - const pathBehavior = worktreePath - ? env.adoptThrowsForPath.get(worktreePath) - : undefined; - if (pathBehavior) - throw trpcErr(pathBehavior.code, pathBehavior.message); - const behavior = env.adoptThrowsFor.get(branch); - if (behavior) throw trpcErr(behavior.code, behavior.message); - const key = `${projectId}:${worktreePath ?? branch}`; - const existingId = env.hostWorkspacesByKey.get(key); - if (existingId) { - return { - workspace: { id: existingId, branch }, - terminals: [], - warnings: [], - }; - } - if (existingWorkspaceId) { - env.hostWorkspacesByKey.set(key, existingWorkspaceId); - return { - workspace: { id: existingWorkspaceId, branch }, - terminals: [], - warnings: [], - }; - } - const workspaceId = nextId("v2-ws"); - env.createdWorkspaceIds.push(workspaceId); - env.hostWorkspacesByKey.set(key, workspaceId); - return { - workspace: { id: workspaceId, branch }, - terminals: [], - warnings: [], - }; - }, - }, - }, - }; - return stub as unknown as HostServiceClient; -} - -function makeCollections(): OrgCollections { - const make = >(keyOf: (v: T) => string) => { - const store = new Map(); - return { - get: (k: string) => store.get(k), - insert: (v: T) => { - store.set(keyOf(v), v); - }, - }; - }; - return { - // Only the 3 collections migrate.ts + writeSidebarState touch matter. - v2SidebarProjects: make((v: { projectId: string }) => v.projectId), - v2SidebarSections: make((v: { sectionId: string }) => v.sectionId), - v2WorkspaceLocalState: make((v: { workspaceId: string }) => v.workspaceId), - } as unknown as OrgCollections; -} - -const ORG = "org-1"; - -function project( - id: string, - overrides: Partial = {}, -): V1ProjectRow { - return { - id, - name: `project-${id}`, - mainRepoPath: `/repos/${id}`, - tabOrder: 0, - defaultApp: null, - ...overrides, - }; -} - -function workspace( - id: string, - projectId: string, - overrides: Partial = {}, -): V1WorkspaceRow { - return { - id, - projectId, - worktreeId: null, - type: "branch", - branch: `branch-${id}`, - name: `workspace-${id}`, - sectionId: null, - tabOrder: 0, - ...overrides, - }; -} - -describe("migrateV1DataToV2", () => { - test("happy path: creates projects and adopts workspaces", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1"), project("p2")], - v1Workspaces: [ - workspace("w1", "p1", { worktreeId: "wt1", type: "worktree" }), - workspace("w2", "p2", { worktreeId: "wt2", type: "worktree" }), - ], - v1Worktrees: [ - { id: "wt1", path: "/worktrees/w1" }, - { id: "wt2", path: "/worktrees/w2" }, - ], - }); - - const summary = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - expect(summary.projectsCreated).toBe(2); - expect(summary.projectsLinked).toBe(0); - expect(summary.workspacesCreated).toBe(2); - expect(summary.workspacesSkipped).toBe(0); - expect(summary.errors).toHaveLength(0); - expect(env.state.size).toBe(4); - }); - - test("findByPath hit links to existing v2 project", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1")], - v1Workspaces: [], - findByPath: new Map([ - ["/repos/p1", { candidates: [{ id: "v2-existing" }] }], - ]), - }); - - const summary = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - expect(summary.projectsLinked).toBe(1); - expect(summary.projectsCreated).toBe(0); - expect(env.state.get("project:p1")?.v2Id).toBe("v2-existing"); - expect(env.state.get("project:p1")?.status).toBe("linked"); - }); - - test("CONFLICT on setup after link records an error", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1")], - findByPath: new Map([ - ["/repos/p1", { candidates: [{ id: "v2-existing" }] }], - ]), - setupThrowsFor: new Set(["v2-existing"]), - }); - - const summary = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - expect(summary.projectsLinked).toBe(0); - expect(summary.projectsErrored).toBe(1); - expect(summary.errors).toHaveLength(1); - expect(env.state.get("project:p1")?.status).toBe("error"); - }); - - test("project create failure records error and skips its workspaces", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p-bad"), project("p-good")], - v1Workspaces: [ - workspace("w1", "p-bad", { worktreeId: "wt1", type: "worktree" }), - workspace("w2", "p-good", { worktreeId: "wt2", type: "worktree" }), - ], - v1Worktrees: [ - { id: "wt1", path: "/a" }, - { id: "wt2", path: "/b" }, - ], - createThrowsFor: new Set(["project-p-bad"]), - }); - - const summary = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - expect(summary.projectsErrored).toBe(1); - expect(summary.projectsCreated).toBe(1); - expect(summary.workspacesCreated).toBe(1); // w2 only - expect(summary.workspacesSkipped).toBe(1); // w1 skipped (parent error) - expect(env.state.get("workspace:w1")?.reason).toBe( - "parent_project_unresolved", - ); - }); - - test("orphan workspace (missing worktree row) is skipped", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1")], - v1Workspaces: [ - workspace("w-orphan", "p1", { - type: "worktree", - worktreeId: "missing", - }), - ], - v1Worktrees: [], - adoptThrowsFor: new Map([["branch-w-orphan", { code: "NOT_FOUND" }]]), - }); - - const summary = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - expect(summary.workspacesSkipped).toBe(1); - expect(summary.workspacesCreated).toBe(0); - expect(env.state.get("workspace:w-orphan")?.reason).toBe( - "worktree_not_registered", - ); - }); - - test("missing v1 worktree row falls back to branch adoption", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1")], - v1Workspaces: [ - workspace("w1", "p1", { - type: "worktree", - worktreeId: "missing", - }), - ], - v1Worktrees: [], - }); - - const summary = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - expect(summary.workspacesCreated).toBe(1); - expect(summary.workspacesSkipped).toBe(0); - expect(env.adoptCalls).toContainEqual({ - projectId: "v2-proj-1", - branch: "branch-w1", - worktreePath: undefined, - baseBranch: undefined, - }); - expect(env.state.get("workspace:w1")?.status).toBe("success"); - }); - - test("adopt NOT_FOUND is skipped, not errored", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1")], - v1Workspaces: [ - workspace("w1", "p1", { - branch: "gone", - worktreeId: "wt1", - type: "worktree", - }), - ], - v1Worktrees: [{ id: "wt1", path: "/gone" }], - adoptThrowsFor: new Map([["gone", { code: "NOT_FOUND" }]]), - }); - - const summary = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - expect(summary.workspacesSkipped).toBe(1); - expect(summary.workspacesErrored).toBe(0); - expect(env.state.get("workspace:w1")?.reason).toBe( - "worktree_not_registered", - ); - }); - - test("stale v1 worktree path falls back to branch adoption", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1")], - v1Workspaces: [ - workspace("w1", "p1", { - worktreeId: "wt1", - type: "worktree", - }), - ], - v1Worktrees: [{ id: "wt1", path: "/stale-worktree" }], - adoptThrowsForPath: new Map([["/stale-worktree", { code: "NOT_FOUND" }]]), - }); - - const summary = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - expect(summary.workspacesCreated).toBe(1); - expect(summary.workspacesSkipped).toBe(0); - expect(env.adoptCalls).toEqual([ - { - projectId: "v2-proj-1", - branch: "branch-w1", - worktreePath: "/stale-worktree", - baseBranch: undefined, - }, - { - projectId: "v2-proj-1", - branch: "branch-w1", - worktreePath: undefined, - baseBranch: undefined, - }, - ]); - expect(env.state.get("workspace:w1")?.status).toBe("success"); - }); - - test("adopt non-NOT_FOUND error is recorded as error", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1")], - v1Workspaces: [ - workspace("w1", "p1", { - branch: "boom", - worktreeId: "wt1", - type: "worktree", - }), - ], - v1Worktrees: [{ id: "wt1", path: "/x" }], - adoptThrowsFor: new Map([ - ["boom", { code: "INTERNAL_SERVER_ERROR", message: "cloud down" }], - ]), - }); - - const summary = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - expect(summary.workspacesErrored).toBe(1); - expect(summary.errors).toHaveLength(1); - expect(env.state.get("workspace:w1")?.status).toBe("error"); - }); - - test("other-org state does not block migration for the active organization", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1")], - state: new Map([ - [ - "project:p1", - { - v1Id: "p1", - v2Id: "v2-other-org-project", - organizationId: "some-other-org", - kind: "project", - status: "success", - reason: null, - }, - ], - ]), - }); - - const summary = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - expect(summary.projectsCreated).toBe(1); - expect(summary.errors).toHaveLength(0); - expect(env.state.get("project:p1")?.organizationId).toBe(ORG); - expect(env.state.get("project:p1")?.status).toBe("success"); - }); - - test("rerun skips rows already in success/linked state, retries error rows and skipped workspaces", async () => { - // Pre-populate state as if a prior run completed p1 but errored on p2. - const prior = new Map([ - [ - "project:p1", - { - v1Id: "p1", - v2Id: "v2-p1", - organizationId: ORG, - kind: "project", - status: "success", - reason: null, - }, - ], - [ - "project:p2", - { - v1Id: "p2", - v2Id: null, - organizationId: ORG, - kind: "project", - status: "error", - reason: "prior failure", - }, - ], - [ - "workspace:w2", - { - v1Id: "w2", - v2Id: null, - organizationId: ORG, - kind: "workspace", - status: "skipped", - reason: "parent_project_unresolved", - }, - ], - ]); - const env = makeFakeEnv({ - v1Projects: [project("p1"), project("p2")], - v1Workspaces: [ - workspace("w2", "p2", { worktreeId: "wt2", type: "worktree" }), - ], - v1Worktrees: [{ id: "wt2", path: "/worktrees/w2" }], - state: prior, - }); - - const summary = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - // p1 was success → skipped. p2 was error → retried and succeeded this run. - expect(summary.projectsCreated).toBe(1); - expect(summary.workspacesCreated).toBe(1); - expect(env.state.get("project:p2")?.status).toBe("success"); - expect(env.state.get("workspace:w2")?.status).toBe("success"); - expect(env.state.get("project:p1")?.status).toBe("success"); // unchanged - expect(env.setupCalls).toContainEqual({ - projectId: "v2-p1", - repoPath: "/repos/p1", - }); - }); - - test("idempotent rerun reports already synced rows without counting them as changes", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1")], - v1Workspaces: [ - workspace("w1", "p1", { worktreeId: "wt1", type: "worktree" }), - ], - v1Worktrees: [{ id: "wt1", path: "/worktrees/w1" }], - hostWorkspacesByKey: new Map([["v2-p1:/worktrees/w1", "v2-w1"]]), - state: new Map([ - [ - "project:p1", - { - v1Id: "p1", - v2Id: "v2-p1", - organizationId: ORG, - kind: "project", - status: "success", - reason: null, - }, - ], - [ - "workspace:w1", - { - v1Id: "w1", - v2Id: "v2-w1", - organizationId: ORG, - kind: "workspace", - status: "success", - reason: null, - }, - ], - ]), - }); - - const summary = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - expect(summary.projectsCreated).toBe(0); - expect(summary.projectsLinked).toBe(0); - expect(summary.workspacesCreated).toBe(0); - expect(summary.workspacesSkipped).toBe(0); - expect(summary.projects).toEqual([ - { name: "project-p1", status: "synced", reason: "Already imported" }, - ]); - expect(summary.workspaces).toEqual([ - { - name: "workspace-w1", - branch: "branch-w1", - status: "synced", - reason: "Already imported", - }, - ]); - expect(env.adoptCalls).toHaveLength(0); - }); - - test("running a completed migration again does not create duplicate projects or workspaces", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1")], - v1Workspaces: [ - workspace("w1", "p1", { worktreeId: "wt1", type: "worktree" }), - ], - v1Worktrees: [{ id: "wt1", path: "/worktrees/w1" }], - }); - const electronTrpc = makeElectronTrpc(env); - const hostService = makeHostService(env); - const collections = makeCollections(); - - const first = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc, - hostService, - collections, - }); - - expect(first.projectsCreated).toBe(1); - expect(first.workspacesCreated).toBe(1); - expect(env.createCalls).toHaveLength(1); - expect(env.adoptCalls).toHaveLength(1); - expect(env.createdWorkspaceIds).toHaveLength(1); - - const second = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc, - hostService, - collections, - }); - - expect(second.projectsCreated).toBe(0); - expect(second.projectsLinked).toBe(0); - expect(second.workspacesCreated).toBe(0); - expect(second.workspacesErrored).toBe(0); - expect(second.projects).toEqual([ - { name: "project-p1", status: "synced", reason: "Already imported" }, - ]); - expect(second.workspaces).toEqual([ - { - name: "workspace-w1", - branch: "branch-w1", - status: "synced", - reason: "Already imported", - }, - ]); - expect(env.createCalls).toHaveLength(1); - expect(env.adoptCalls).toHaveLength(1); - expect(env.createdWorkspaceIds).toHaveLength(1); - expect(env.setupCalls).toEqual([ - { projectId: "v2-proj-1", repoPath: "/repos/p1" }, - ]); - }); - - test("project state write failure does not migrate child workspaces until rerun reconciles the project", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1")], - v1Workspaces: [ - workspace("w1", "p1", { worktreeId: "wt1", type: "worktree" }), - ], - v1Worktrees: [{ id: "wt1", path: "/worktrees/w1" }], - failNextStateWriteFor: new Set(["project:p1"]), - }); - const electronTrpc = makeElectronTrpc(env); - const hostService = makeHostService(env); - - const first = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc, - hostService, - collections: makeCollections(), - }); - - expect(first.projectsErrored).toBe(1); - expect(first.workspacesSkipped).toBe(1); - expect(env.createCalls).toHaveLength(1); - expect(env.createdProjectIds).toEqual(["v2-proj-1"]); - expect(env.adoptCalls).toHaveLength(0); - expect(env.state.get("project:p1")?.status).toBe("error"); - expect(env.state.get("workspace:w1")?.reason).toBe( - "parent_project_unresolved", - ); - - const second = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc, - hostService, - collections: makeCollections(), - }); - - expect(second.projectsLinked).toBe(1); - expect(second.workspacesCreated).toBe(1); - expect(env.createCalls).toHaveLength(1); - expect(env.createdProjectIds).toEqual(["v2-proj-1"]); - expect(env.state.get("project:p1")?.v2Id).toBe("v2-proj-1"); - expect(env.state.get("project:p1")?.status).toBe("linked"); - expect(env.state.get("workspace:w1")?.status).toBe("success"); - }); - - test("workspace state write failure reruns adoption without creating a duplicate workspace", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1")], - v1Workspaces: [ - workspace("w1", "p1", { worktreeId: "wt1", type: "worktree" }), - ], - v1Worktrees: [{ id: "wt1", path: "/worktrees/w1" }], - failNextStateWriteFor: new Set(["workspace:w1"]), - }); - const electronTrpc = makeElectronTrpc(env); - const hostService = makeHostService(env); - - const first = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc, - hostService, - collections: makeCollections(), - }); - - expect(first.workspacesErrored).toBe(1); - expect(env.createdWorkspaceIds).toEqual(["v2-ws-2"]); - expect(env.state.get("workspace:w1")?.status).toBe("error"); - - const second = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc, - hostService, - collections: makeCollections(), - }); - - expect(second.workspacesCreated).toBe(1); - expect(env.createdWorkspaceIds).toEqual(["v2-ws-2"]); - expect(env.state.get("workspace:w1")?.status).toBe("success"); - expect(env.state.get("workspace:w1")?.v2Id).toBe("v2-ws-2"); - }); - - test("completed workspace with missing host db row is relinked to existing cloud workspace", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1")], - v1Workspaces: [ - workspace("w1", "p1", { worktreeId: "wt1", type: "worktree" }), - ], - v1Worktrees: [{ id: "wt1", path: "/worktrees/w1" }], - state: new Map([ - [ - "project:p1", - { - v1Id: "p1", - v2Id: "v2-p1", - organizationId: ORG, - kind: "project", - status: "success", - reason: null, - }, - ], - [ - "workspace:w1", - { - v1Id: "w1", - v2Id: "v2-ws-existing", - organizationId: ORG, - kind: "workspace", - status: "success", - reason: null, - }, - ], - ]), - }); - - const summary = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - expect(summary.workspacesCreated).toBe(1); - expect(env.createdWorkspaceIds).toEqual([]); - expect(env.adoptCalls).toContainEqual({ - projectId: "v2-p1", - branch: "branch-w1", - worktreePath: "/worktrees/w1", - baseBranch: undefined, - existingWorkspaceId: "v2-ws-existing", - }); - expect(env.state.get("workspace:w1")?.status).toBe("success"); - expect(env.state.get("workspace:w1")?.v2Id).toBe("v2-ws-existing"); - }); - - test("rerun retries previous worktree skips so old skipped state can recover", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1")], - v1Workspaces: [ - workspace("w-orphan", "p1", { - type: "worktree", - worktreeId: "missing", - }), - ], - v1Worktrees: [], - state: new Map([ - [ - "project:p1", - { - v1Id: "p1", - v2Id: "v2-p1", - organizationId: ORG, - kind: "project", - status: "success", - reason: null, - }, - ], - [ - "workspace:w-orphan", - { - v1Id: "w-orphan", - v2Id: null, - organizationId: ORG, - kind: "workspace", - status: "skipped", - reason: "orphan_worktree", - }, - ], - ]), - }); - - const summary = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - expect(summary.workspacesCreated).toBe(1); - expect(summary.workspacesSkipped).toBe(0); - expect(env.adoptCalls).toContainEqual({ - projectId: "v2-p1", - branch: "branch-w-orphan", - worktreePath: undefined, - baseBranch: undefined, - }); - expect(env.state.get("workspace:w-orphan")?.status).toBe("success"); - }); - - test("failed retry of previous missing-worktree skip does not count as new work", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1")], - v1Workspaces: [ - workspace("w-orphan", "p1", { - type: "worktree", - worktreeId: "missing", - }), - ], - v1Worktrees: [], - adoptThrowsFor: new Map([["branch-w-orphan", { code: "NOT_FOUND" }]]), - state: new Map([ - [ - "project:p1", - { - v1Id: "p1", - v2Id: "v2-p1", - organizationId: ORG, - kind: "project", - status: "success", - reason: null, - }, - ], - [ - "workspace:w-orphan", - { - v1Id: "w-orphan", - v2Id: null, - organizationId: ORG, - kind: "workspace", - status: "skipped", - reason: "worktree_not_registered", - }, - ], - ]), - }); - - const summary = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - expect(summary.workspacesCreated).toBe(0); - expect(summary.workspacesSkipped).toBe(0); - expect(env.adoptCalls).toHaveLength(1); - expect(summary.workspaces).toHaveLength(1); - expect(summary.workspaces).toContainEqual({ - name: "workspace-w-orphan", - branch: "branch-w-orphan", - status: "skipped", - reason: "worktree no longer exists", - }); - expect(env.state.get("workspace:w-orphan")?.status).toBe("skipped"); - expect(env.state.get("workspace:w-orphan")?.reason).toBe( - "worktree_not_registered", - ); - }); - - test("passes v1 worktree base branch into adoption", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1")], - v1Workspaces: [ - workspace("w1", "p1", { worktreeId: "wt1", type: "worktree" }), - ], - v1Worktrees: [ - { id: "wt1", path: "/worktrees/w1", baseBranch: "develop" }, - ], - }); - - await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - expect(env.adoptCalls).toContainEqual({ - projectId: "v2-proj-1", - branch: "branch-w1", - worktreePath: "/worktrees/w1", - baseBranch: "develop", - }); - }); - - test("workspace with sectionId that lacks a v1 section record lands at top level", async () => { - const env = makeFakeEnv({ - v1Projects: [project("p1")], - v1Workspaces: [ - workspace("w1", "p1", { - worktreeId: "wt1", - type: "worktree", - sectionId: "sec-missing", - }), - ], - v1Worktrees: [{ id: "wt1", path: "/a" }], - v1Sections: [], - }); - - const summary = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - expect(summary.workspacesCreated).toBe(1); - expect(env.state.get("workspace:w1")?.status).toBe("success"); - }); - - test("no v1 data → no-op, no errors, empty summary", async () => { - const env = makeFakeEnv({}); - const summary = await migrateV1DataToV2({ - organizationId: ORG, - electronTrpc: makeElectronTrpc(env), - hostService: makeHostService(env), - collections: makeCollections(), - }); - - expect(summary.projectsCreated).toBe(0); - expect(summary.projectsLinked).toBe(0); - expect(summary.workspacesCreated).toBe(0); - expect(summary.errors).toHaveLength(0); - }); -}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts deleted file mode 100644 index 09fb79ba456..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts +++ /dev/null @@ -1,512 +0,0 @@ -import type { HostServiceClient } from "renderer/lib/host-service-client"; -import type { electronTrpcClient } from "renderer/lib/trpc-client"; -import type { OrgCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; -import { writeV2SidebarState } from "./writeSidebarState"; - -type ElectronTrpcClient = typeof electronTrpcClient; - -export type ProjectStatus = "created" | "linked" | "synced" | "error"; -export type WorkspaceStatus = "adopted" | "synced" | "skipped" | "error"; - -export interface ProjectEntry { - name: string; - status: ProjectStatus; - reason?: string; -} - -export interface WorkspaceEntry { - name: string; - branch: string; - status: WorkspaceStatus; - reason?: string; -} - -export interface MigrationSummary { - projectsCreated: number; - projectsLinked: number; - projectsErrored: number; - workspacesCreated: number; - workspacesSkipped: number; - workspacesErrored: number; - projects: ProjectEntry[]; - workspaces: WorkspaceEntry[]; - errors: Array<{ - kind: "project" | "workspace"; - name: string; - message: string; - }>; -} - -const emptySummary = (): MigrationSummary => ({ - projectsCreated: 0, - projectsLinked: 0, - projectsErrored: 0, - workspacesCreated: 0, - workspacesSkipped: 0, - workspacesErrored: 0, - projects: [], - workspaces: [], - errors: [], -}); - -function trpcCode(err: unknown): string | null { - if (typeof err !== "object" || err === null) return null; - const data = (err as { data?: unknown }).data; - if (typeof data !== "object" || data === null) return null; - const code = (data as { code?: unknown }).code; - return typeof code === "string" ? code : null; -} - -function errorMessage(err: unknown): string { - if (err instanceof Error) return err.message; - return String(err); -} - -async function setupProjectImport( - hostService: HostServiceClient, - projectId: string, - repoPath: string, -): Promise { - await hostService.project.setup.mutate({ - projectId, - mode: { kind: "import", repoPath }, - }); -} - -function shouldRetryWorkspace( - existing: { status: string; reason?: string | null } | undefined, -): boolean { - if (!existing) return true; - if (existing.status === "success") return false; - if (existing.status === "error") return true; - return ( - existing.status === "skipped" && - (existing.reason === "parent_project_unresolved" || - existing.reason === "orphan_worktree" || - existing.reason === "worktree_not_registered") - ); -} - -async function hasLocalWorkspace( - hostService: HostServiceClient, - workspaceId: string, -): Promise { - try { - await hostService.workspace.get.query({ id: workspaceId }); - return true; - } catch (err) { - if (trpcCode(err) === "NOT_FOUND") return false; - throw err; - } -} - -function addProjectError( - summary: MigrationSummary, - name: string, - message: string, -): void { - summary.projectsErrored += 1; - summary.projects.push({ - name, - status: "error", - reason: message, - }); - summary.errors.push({ - kind: "project", - name, - message, - }); -} - -function addWorkspaceSkip( - summary: MigrationSummary, - name: string, - branch: string, - reason: string, -): void { - summary.workspacesSkipped += 1; - summary.workspaces.push({ - name, - branch, - status: "skipped", - reason, - }); -} - -function skippedWorkspaceReason(reason: string | null | undefined): string { - switch (reason) { - case "orphan_worktree": - return "worktree record missing"; - case "worktree_not_registered": - return "worktree no longer exists"; - case "parent_project_unresolved": - return "parent project did not migrate"; - default: - return reason ?? "skipped"; - } -} - -function wasAlreadyMissingWorktreeSkip( - existing: { status: string; reason?: string | null } | undefined, -): boolean { - return ( - existing?.status === "skipped" && - (existing.reason === "orphan_worktree" || - existing.reason === "worktree_not_registered") - ); -} - -function addWorkspaceError( - summary: MigrationSummary, - name: string, - branch: string, - message: string, -): void { - summary.workspacesErrored += 1; - summary.workspaces.push({ - name, - branch, - status: "error", - reason: message, - }); - summary.errors.push({ - kind: "workspace", - name, - message, - }); -} - -interface Args { - organizationId: string; - electronTrpc: ElectronTrpcClient; - hostService: HostServiceClient; - collections: OrgCollections; -} - -export async function migrateV1DataToV2(args: Args): Promise { - const { organizationId, electronTrpc, hostService, collections } = args; - const summary = emptySummary(); - - const [v1Projects, v1Workspaces, v1Worktrees, v1Sections, existingState] = - await Promise.all([ - electronTrpc.migration.readV1Projects.query(), - electronTrpc.migration.readV1Workspaces.query(), - electronTrpc.migration.readV1Worktrees.query(), - electronTrpc.migration.readV1WorkspaceSections.query(), - electronTrpc.migration.listState.query({ organizationId }), - ]); - - const stateByKey = new Map(); - for (const row of existingState) { - stateByKey.set(`${row.kind}:${row.v1Id}`, row); - } - - const worktreesById = new Map(); - for (const wt of v1Worktrees) worktreesById.set(wt.id, wt); - - const projectV1ToV2 = new Map(); - for (const row of existingState) { - if ( - row.kind === "project" && - row.v2Id && - (row.status === "success" || row.status === "linked") - ) { - projectV1ToV2.set(row.v1Id, row.v2Id); - } - } - - const workspaceV1ToV2 = new Map(); - for (const row of existingState) { - if (row.kind === "workspace" && row.v2Id && row.status === "success") { - workspaceV1ToV2.set(row.v1Id, row.v2Id); - } - } - - for (const project of v1Projects) { - const key = `project:${project.id}`; - const existing = stateByKey.get(key); - if ( - existing?.v2Id && - (existing.status === "success" || existing.status === "linked") - ) { - try { - await setupProjectImport( - hostService, - existing.v2Id, - project.mainRepoPath, - ); - projectV1ToV2.set(project.id, existing.v2Id); - summary.projects.push({ - name: project.name, - status: "synced", - reason: "Already imported", - }); - } catch (err) { - const message = errorMessage(err); - await electronTrpc.migration.upsertState.mutate({ - v1Id: project.id, - kind: "project", - v2Id: existing.v2Id, - organizationId, - status: "error", - reason: message, - }); - projectV1ToV2.delete(project.id); - addProjectError(summary, project.name, message); - console.error( - "[v1-migration] existing project setup failed", - project.name, - err, - ); - } - continue; - } - - try { - const found = await hostService.project.findByPath.query({ - repoPath: project.mainRepoPath, - }); - - let v2ProjectId: string; - let status: "success" | "linked"; - - if (found.candidates.length > 0) { - const candidate = found.candidates[0]; - if (!candidate) throw new Error("findByPath returned empty candidate"); - if (found.candidates.length > 1) { - console.warn( - `[v1-migration] findByPath for ${project.mainRepoPath} returned ${found.candidates.length} candidates; migration has no project picker, linking to first (${candidate.id})`, - ); - } - v2ProjectId = candidate.id; - status = "linked"; - await setupProjectImport( - hostService, - candidate.id, - project.mainRepoPath, - ); - } else { - const created = await hostService.project.create.mutate({ - name: project.name, - mode: { - kind: "importLocal", - repoPath: project.mainRepoPath, - }, - }); - v2ProjectId = created.projectId; - status = "success"; - } - - await electronTrpc.migration.upsertState.mutate({ - v1Id: project.id, - kind: "project", - v2Id: v2ProjectId, - organizationId, - status, - reason: null, - }); - projectV1ToV2.set(project.id, v2ProjectId); - if (status === "success") { - summary.projectsCreated += 1; - summary.projects.push({ name: project.name, status: "created" }); - } else { - summary.projectsLinked += 1; - summary.projects.push({ name: project.name, status: "linked" }); - } - } catch (err) { - const message = errorMessage(err); - projectV1ToV2.delete(project.id); - await electronTrpc.migration.upsertState.mutate({ - v1Id: project.id, - kind: "project", - v2Id: null, - organizationId, - status: "error", - reason: message, - }); - addProjectError(summary, project.name, message); - console.error("[v1-migration] project failed", project.name, err); - } - } - - for (const workspace of v1Workspaces) { - const key = `workspace:${workspace.id}`; - const existing = stateByKey.get(key); - let recoverCompletedWorkspace = false; - if (existing?.status === "success" && existing.v2Id) { - try { - if (await hasLocalWorkspace(hostService, existing.v2Id)) { - workspaceV1ToV2.set(workspace.id, existing.v2Id); - summary.workspaces.push({ - name: workspace.name, - branch: workspace.branch, - status: "synced", - reason: "Already imported", - }); - continue; - } - recoverCompletedWorkspace = true; - } catch (err) { - const message = errorMessage(err); - await electronTrpc.migration.upsertState.mutate({ - v1Id: workspace.id, - kind: "workspace", - v2Id: existing.v2Id, - organizationId, - status: "error", - reason: message, - }); - addWorkspaceError(summary, workspace.name, workspace.branch, message); - console.error( - "[v1-migration] workspace local reconciliation failed", - workspace.name, - err, - ); - continue; - } - } - if (!recoverCompletedWorkspace && !shouldRetryWorkspace(existing)) { - if (existing?.status === "skipped") { - summary.workspaces.push({ - name: workspace.name, - branch: workspace.branch, - status: "skipped", - reason: skippedWorkspaceReason(existing.reason), - }); - } - continue; - } - - const v2ProjectId = projectV1ToV2.get(workspace.projectId); - if (!v2ProjectId) { - await electronTrpc.migration.upsertState.mutate({ - v1Id: workspace.id, - kind: "workspace", - v2Id: null, - organizationId, - status: "skipped", - reason: "parent_project_unresolved", - }); - addWorkspaceSkip( - summary, - workspace.name, - workspace.branch, - "parent project did not migrate", - ); - continue; - } - - const v1Worktree = workspace.worktreeId - ? worktreesById.get(workspace.worktreeId) - : undefined; - // FORK NOTE: branch-type workspaces (no worktreeId) fall back to the - // parent project's mainRepoPath so adopt() can resolve the branch - // inside the primary checkout. listWorktreeBranches excludes the - // primary working tree, otherwise these workspaces skip with - // "worktree_not_registered". - const v1WorktreePath = - v1Worktree?.path ?? - (workspace.type === "branch" - ? v1Projects.find((p) => p.id === workspace.projectId)?.mainRepoPath - : undefined); - const v1BaseBranch = v1Worktree?.baseBranch; - - const adoptWorkspace = (worktreePath: string | undefined) => - hostService.workspaceCreation.adopt.mutate({ - projectId: v2ProjectId, - workspaceName: workspace.name, - branch: workspace.branch, - baseBranch: v1BaseBranch ?? undefined, - existingWorkspaceId: existing?.v2Id ?? undefined, - worktreePath, - }); - - const recordAdoptFailure = async (err: unknown) => { - if (trpcCode(err) === "NOT_FOUND") { - const reason = "worktree_not_registered"; - await electronTrpc.migration.upsertState.mutate({ - v1Id: workspace.id, - kind: "workspace", - v2Id: null, - organizationId, - status: "skipped", - reason, - }); - if (wasAlreadyMissingWorktreeSkip(existing)) { - summary.workspaces.push({ - name: workspace.name, - branch: workspace.branch, - status: "skipped", - reason: skippedWorkspaceReason(reason), - }); - return; - } - addWorkspaceSkip( - summary, - workspace.name, - workspace.branch, - "worktree no longer exists", - ); - return; - } - const message = errorMessage(err); - await electronTrpc.migration.upsertState.mutate({ - v1Id: workspace.id, - kind: "workspace", - v2Id: null, - organizationId, - status: "error", - reason: message, - }); - addWorkspaceError(summary, workspace.name, workspace.branch, message); - console.error("[v1-migration] workspace failed", workspace.name, err); - }; - - try { - let result: Awaited>; - try { - result = await adoptWorkspace(v1WorktreePath); - } catch (err) { - if (trpcCode(err) !== "NOT_FOUND" || !v1WorktreePath) { - throw err; - } - - // v1 worktree rows can be stale while git still has the branch - // registered at a different path. Retry by branch before giving up. - result = await adoptWorkspace(undefined); - } - - await electronTrpc.migration.upsertState.mutate({ - v1Id: workspace.id, - kind: "workspace", - v2Id: result.workspace.id, - organizationId, - status: "success", - reason: null, - }); - workspaceV1ToV2.set(workspace.id, result.workspace.id); - summary.workspacesCreated += 1; - summary.workspaces.push({ - name: workspace.name, - branch: workspace.branch, - status: "adopted", - }); - } catch (err) { - await recordAdoptFailure(err); - } - } - - // Translate all sidebar state (project order, sections, workspace order + - // section membership) in one pass. Main loop above only handles cloud + - // host-service creates and records migration_state; renderer-side - // collection writes live entirely in writeV2SidebarState. - writeV2SidebarState(collections, { - projectV1ToV2, - workspaceV1ToV2, - v1Projects, - v1Sections, - v1Workspaces, - }); - - return summary; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/normalize.test.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/normalize.test.ts deleted file mode 100644 index 321282873cb..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/normalize.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { computeNormalizedOrders } from "./normalize"; - -const project = "p-1"; - -function workspace( - id: string, - tabOrder: number, - sectionId: string | null = null, - projectId: string = project, -) { - return { id, projectId, sectionId, tabOrder }; -} - -function section(id: string, tabOrder: number, projectId: string = project) { - return { id, projectId, tabOrder }; -} - -describe("computeNormalizedOrders", () => { - test("empty input returns empty maps", () => { - const result = computeNormalizedOrders({ workspaces: [], sections: [] }); - expect(result.workspaceTabOrder.size).toBe(0); - expect(result.sectionTabOrder.size).toBe(0); - }); - - test("top-level workspaces only — preserve relative order", () => { - const { workspaceTabOrder } = computeNormalizedOrders({ - workspaces: [workspace("a", 5), workspace("b", 2), workspace("c", 9)], - sections: [], - }); - expect(workspaceTabOrder.get("b")).toBe(0); - expect(workspaceTabOrder.get("a")).toBe(1); - expect(workspaceTabOrder.get("c")).toBe(2); - }); - - test("sections only — preserve relative order", () => { - const { sectionTabOrder } = computeNormalizedOrders({ - workspaces: [], - sections: [section("s1", 10), section("s2", 3)], - }); - expect(sectionTabOrder.get("s2")).toBe(0); - expect(sectionTabOrder.get("s1")).toBe(1); - }); - - test("workspaces placed before sections in combined space", () => { - const { workspaceTabOrder, sectionTabOrder } = computeNormalizedOrders({ - workspaces: [workspace("a", 0), workspace("b", 1)], - sections: [section("s1", 2)], - }); - expect(workspaceTabOrder.get("a")).toBe(0); - expect(workspaceTabOrder.get("b")).toBe(1); - expect(sectionTabOrder.get("s1")).toBe(2); - }); - - test("interleaved v1 layout flattens to workspaces-then-sections", () => { - // v1: [Section A (0), Workspace X (1), Section B (2), Workspace Y (3)] - const { workspaceTabOrder, sectionTabOrder } = computeNormalizedOrders({ - workspaces: [workspace("X", 1), workspace("Y", 3)], - sections: [section("A", 0), section("B", 2)], - }); - // Top-level workspaces first: X (was 1), Y (was 3) → 0, 1 - expect(workspaceTabOrder.get("X")).toBe(0); - expect(workspaceTabOrder.get("Y")).toBe(1); - // Sections after: A (was 0), B (was 2) → 2, 3 - expect(sectionTabOrder.get("A")).toBe(2); - expect(sectionTabOrder.get("B")).toBe(3); - }); - - test("workspaces inside a section keep within-section order", () => { - const { workspaceTabOrder } = computeNormalizedOrders({ - workspaces: [ - workspace("inner-a", 5, "sec-1"), - workspace("inner-b", 1, "sec-1"), - workspace("inner-c", 3, "sec-1"), - ], - sections: [section("sec-1", 0)], - }); - expect(workspaceTabOrder.get("inner-b")).toBe(0); - expect(workspaceTabOrder.get("inner-c")).toBe(1); - expect(workspaceTabOrder.get("inner-a")).toBe(2); - }); - - test("mixed top-level + in-section workspaces are independent", () => { - const { workspaceTabOrder, sectionTabOrder } = computeNormalizedOrders({ - workspaces: [ - workspace("top-1", 0), - workspace("top-2", 1), - workspace("in-a", 7, "sec-1"), - workspace("in-b", 2, "sec-1"), - ], - sections: [section("sec-1", 5)], - }); - // Top-level: top-1=0, top-2=1 - expect(workspaceTabOrder.get("top-1")).toBe(0); - expect(workspaceTabOrder.get("top-2")).toBe(1); - // Section tabOrder = 2 (after the 2 top-level workspaces) - expect(sectionTabOrder.get("sec-1")).toBe(2); - // In-section: in-b (v1 tabOrder=2) before in-a (v1 tabOrder=7) - expect(workspaceTabOrder.get("in-b")).toBe(0); - expect(workspaceTabOrder.get("in-a")).toBe(1); - }); - - test("multiple projects are independent", () => { - const { workspaceTabOrder, sectionTabOrder } = computeNormalizedOrders({ - workspaces: [ - workspace("p1-w1", 0, null, "p1"), - workspace("p2-w1", 0, null, "p2"), - ], - sections: [section("p1-sec", 1, "p1"), section("p2-sec", 1, "p2")], - }); - expect(workspaceTabOrder.get("p1-w1")).toBe(0); - expect(sectionTabOrder.get("p1-sec")).toBe(1); - expect(workspaceTabOrder.get("p2-w1")).toBe(0); - expect(sectionTabOrder.get("p2-sec")).toBe(1); - }); - - test("sparse/gapped v1 values still produce contiguous output", () => { - const { workspaceTabOrder, sectionTabOrder } = computeNormalizedOrders({ - workspaces: [workspace("a", 0), workspace("b", 100), workspace("c", 250)], - sections: [section("s1", 500)], - }); - expect(workspaceTabOrder.get("a")).toBe(0); - expect(workspaceTabOrder.get("b")).toBe(1); - expect(workspaceTabOrder.get("c")).toBe(2); - expect(sectionTabOrder.get("s1")).toBe(3); - }); -}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/normalize.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/normalize.ts deleted file mode 100644 index c08d41728f0..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/normalize.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * v1 → v2 sidebar order translation. - * - * v1 allowed arbitrary interleaving of top-level workspaces and sections. - * v2 doesn't — if a top-level workspace appears after a section in sort - * order, v2's render absorbs it into that section's display group - * (useDashboardSidebarData.ts:343-357). A direct copy of v1 tab_order - * values would surprise users whose v1 layout had post-section orphans. - * - * Translation: put all top-level workspaces first (in their original - * v1 order), then sections (in their original v1 order). Preserves - * relative ordering within each group; sacrifices interleaving (which - * v2 can't express anyway). Workspaces inside a section keep their - * within-section order. - */ -export interface V1TabOrderInput { - workspaces: Array<{ - id: string; - projectId: string; - sectionId: string | null; - tabOrder: number; - }>; - sections: Array<{ id: string; projectId: string; tabOrder: number }>; -} - -export interface V1TabOrderOutput { - workspaceTabOrder: Map; - sectionTabOrder: Map; -} - -export function computeNormalizedOrders( - input: V1TabOrderInput, -): V1TabOrderOutput { - const workspaceTabOrder = new Map(); - const sectionTabOrder = new Map(); - - const projectIds = new Set(); - for (const w of input.workspaces) projectIds.add(w.projectId); - for (const s of input.sections) projectIds.add(s.projectId); - - for (const projectId of projectIds) { - const topLevelWorkspaces = input.workspaces - .filter((w) => w.projectId === projectId && w.sectionId === null) - .sort((a, b) => a.tabOrder - b.tabOrder); - - const sections = input.sections - .filter((s) => s.projectId === projectId) - .sort((a, b) => a.tabOrder - b.tabOrder); - - topLevelWorkspaces.forEach((w, index) => { - workspaceTabOrder.set(w.id, index); - }); - - sections.forEach((s, index) => { - sectionTabOrder.set(s.id, topLevelWorkspaces.length + index); - }); - - // Workspaces inside sections: keep order relative to their section peers. - const workspacesBySection = new Map< - string, - Array<(typeof input.workspaces)[number]> - >(); - for (const w of input.workspaces) { - if (w.projectId !== projectId || w.sectionId === null) continue; - const group = workspacesBySection.get(w.sectionId) ?? []; - group.push(w); - workspacesBySection.set(w.sectionId, group); - } - for (const [, group] of workspacesBySection) { - group - .sort((a, b) => a.tabOrder - b.tabOrder) - .forEach((w, index) => { - workspaceTabOrder.set(w.id, index); - }); - } - } - - return { workspaceTabOrder, sectionTabOrder }; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/useMigrateV1DataToV2.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/useMigrateV1DataToV2.ts deleted file mode 100644 index adc74bbdff3..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/useMigrateV1DataToV2.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; -import { env } from "renderer/env.renderer"; -import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; -import { authClient } from "renderer/lib/auth-client"; -import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; -import { electronTrpcClient } from "renderer/lib/trpc-client"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; -import { MOCK_ORG_ID } from "shared/constants"; -import { type MigrationSummary, migrateV1DataToV2 } from "./migrate"; - -export type MigrationRunResult = - | { completed: true; summary: MigrationSummary } - | { completed: false; reason: string }; - -function getAttemptKey(organizationId: string): string { - return `v1-migration-attempted-${organizationId}`; -} - -function getSummaryKey(organizationId: string): string { - return `v1-migration-summary-${organizationId}`; -} - -function getShownKey(organizationId: string): string { - return `v1-migration-modal-shown-${organizationId}`; -} - -export const V1_MIGRATION_SUMMARY_EVENT = "v1-migration-summary-updated"; -export const V1_MIGRATION_LAST_RUN_AT_EVENT = V1_MIGRATION_SUMMARY_EVENT; - -/** - * Reads the timestamp (epoch ms) of the most recent v1→v2 migration run for an - * organization, or null if no run has been recorded. - */ -export function readLastMigrationRunAt( - organizationId: string | null, -): number | null { - if (!organizationId) return null; - const raw = localStorage.getItem(getSummaryKey(organizationId)); - if (!raw) return null; - try { - const parsed = JSON.parse(raw) as { createdAt?: number }; - return typeof parsed.createdAt === "number" ? parsed.createdAt : null; - } catch { - return null; - } -} - -function persistSummary(organizationId: string, summary: MigrationSummary) { - localStorage.setItem( - getSummaryKey(organizationId), - JSON.stringify({ summary, createdAt: Date.now() }), - ); - localStorage.setItem(getShownKey(organizationId), "1"); - window.dispatchEvent( - new CustomEvent(V1_MIGRATION_SUMMARY_EVENT, { detail: { organizationId } }), - ); -} - -// Module-level singleton so every hook instance shares the same isRunning value. -// Without this, the auto-run from the dashboard layout and the manual rerun -// from settings each have their own isRunning ref, letting the user start a -// concurrent migration from settings while the auto-run is still in flight. -let activeMigrationCount = 0; -const migrationRunningSubscribers = new Set<() => void>(); - -function subscribeMigrationRunning(notify: () => void) { - migrationRunningSubscribers.add(notify); - return () => { - migrationRunningSubscribers.delete(notify); - }; -} - -function getMigrationRunningSnapshot() { - return activeMigrationCount > 0; -} - -function setMigrationRunning(running: boolean) { - activeMigrationCount = Math.max(0, activeMigrationCount + (running ? 1 : -1)); - for (const notify of migrationRunningSubscribers) notify(); -} - -/** - * Fires v1→v2 migration once per app launch when the dashboard first mounts - * with v2 enabled. Idempotent by design: - * - sessionStorage marker dedups within a session (blocks strict-mode double-invoke) - * - migration_state in the local DB tracks completed rows; subsequent runs - * reconcile success/linked project rows, skip completed workspace rows, and - * retry error rows plus parent-dependent workspace skips - * - * Reruns happen implicitly on app relaunch. No automatic retry timer or online - * listener — v2 requires the cloud to be reachable anyway, so a transient - * offline error resolves on the next launch. - */ -export function useMigrateV1DataToV2({ - autoRun = true, -}: { - autoRun?: boolean; -} = {}) { - const { data: session } = authClient.useSession(); - const { activeHostUrl } = useLocalHostService(); - const { isV2CloudEnabled } = useIsV2CloudEnabled(); - const collections = useCollections(); - const isRunning = useSyncExternalStore( - subscribeMigrationRunning, - getMigrationRunningSnapshot, - getMigrationRunningSnapshot, - ); - const organizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); - const attemptedRef = useRef(null); - - const runMigration = useCallback( - async ({ manual }: { manual: boolean }): Promise => { - if (!isV2CloudEnabled) { - return { completed: false, reason: "Superset v2 is not enabled" }; - } - if (!organizationId) { - return { completed: false, reason: "No active organization" }; - } - if (!activeHostUrl) { - return { completed: false, reason: "Host service is not ready" }; - } - if (activeMigrationCount > 0) { - return { completed: false, reason: "Migration is already running" }; - } - - const attemptKey = getAttemptKey(organizationId); - if (!manual) { - if (attemptedRef.current === organizationId) { - return { - completed: false, - reason: "Migration already ran in this session", - }; - } - if (sessionStorage.getItem(attemptKey) === "1") { - attemptedRef.current = organizationId; - return { - completed: false, - reason: "Migration already ran in this session", - }; - } - } - - attemptedRef.current = organizationId; - sessionStorage.setItem(attemptKey, "1"); - setMigrationRunning(true); - - try { - const hostService = getHostServiceClientByUrl(activeHostUrl); - const summary = await migrateV1DataToV2({ - organizationId, - electronTrpc: electronTrpcClient, - hostService, - collections, - }); - - // Persist summary unconditionally before any early-return paths — it's - // an idempotent side effect and must survive strict-mode effect - // teardowns that can happen between migration completion and here. - const didAnything = - summary.projectsCreated + - summary.projectsLinked + - summary.projectsErrored + - summary.workspacesCreated + - summary.workspacesSkipped + - summary.workspacesErrored > - 0; - const alreadyShown = - localStorage.getItem(getShownKey(organizationId)) === "1"; - if (manual || (didAnything && !alreadyShown)) { - persistSummary(organizationId, summary); - } - - if (summary.errors.length > 0) { - console.error("[v1-migration] errors", summary.errors); - } - return { completed: true, summary }; - } catch (err) { - // Clear marker so a relaunch can retry (e.g., transient cloud unreach - // before session fully hydrated). - sessionStorage.removeItem(attemptKey); - attemptedRef.current = null; - console.error("[v1-migration] fatal", err); - const reason = err instanceof Error ? err.message : String(err); - return { completed: false, reason }; - } finally { - setMigrationRunning(false); - } - }, - [activeHostUrl, collections, isV2CloudEnabled, organizationId], - ); - - useEffect(() => { - if (!autoRun) return; - void runMigration({ manual: false }); - }, [autoRun, runMigration]); - - const rerun = useCallback(async (): Promise => { - if (!organizationId) { - return { completed: false, reason: "No active organization" }; - } - sessionStorage.removeItem(getAttemptKey(organizationId)); - attemptedRef.current = null; - return runMigration({ manual: true }); - }, [organizationId, runMigration]); - - return { rerun, isRunning }; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.test.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.test.ts deleted file mode 100644 index deaa996eb65..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.test.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { OrgCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; -import { - type SidebarInput, - type V1ProjectLike, - type V1SectionLike, - type V1WorkspaceLike, - writeV2SidebarState, -} from "./writeSidebarState"; - -interface InMemoryCollection { - get: (key: TKey) => TValue | undefined; - insert: (value: TValue) => void; - _values: () => TValue[]; -} - -function createCollection>( - getKey: (v: TValue) => string, -): InMemoryCollection { - const store = new Map(); - return { - get: (key: string) => store.get(key), - insert: (value: TValue) => { - store.set(getKey(value), value); - }, - _values: () => Array.from(store.values()), - }; -} - -function makeCollections() { - const v2SidebarProjects = createCollection<{ - projectId: string; - tabOrder: number; - defaultOpenInApp: string | null; - isCollapsed: boolean; - createdAt: Date; - }>((v) => v.projectId); - const v2SidebarSections = createCollection<{ - sectionId: string; - projectId: string; - name: string; - tabOrder: number; - isCollapsed: boolean; - color: string | null; - createdAt: Date; - }>((v) => v.sectionId); - const v2WorkspaceLocalState = createCollection<{ - workspaceId: string; - sidebarState: { - projectId: string; - tabOrder: number; - sectionId: string | null; - changesFilter: { kind: string }; - }; - paneLayout: unknown; - viewedFiles: string[]; - recentlyViewedFiles: unknown[]; - createdAt: Date; - }>((v) => v.workspaceId); - - const collections = { - v2SidebarProjects, - v2SidebarSections, - v2WorkspaceLocalState, - } as unknown as OrgCollections; - - return { - collections, - v2SidebarProjects, - v2SidebarSections, - v2WorkspaceLocalState, - }; -} - -function project( - id: string, - tabOrder: number | null = 0, - defaultApp: string | null = null, -): V1ProjectLike { - return { id, tabOrder, defaultApp }; -} - -function section( - id: string, - projectId: string, - tabOrder: number, - overrides: Partial = {}, -): V1SectionLike { - return { - id, - projectId, - tabOrder, - name: `section-${id}`, - isCollapsed: false, - color: null, - ...overrides, - }; -} - -function workspace( - id: string, - projectId: string, - tabOrder: number, - sectionId: string | null = null, -): V1WorkspaceLike { - return { id, projectId, tabOrder, sectionId }; -} - -function buildInput(partial: Partial): SidebarInput { - return { - projectV1ToV2: partial.projectV1ToV2 ?? new Map(), - workspaceV1ToV2: partial.workspaceV1ToV2 ?? new Map(), - v1Projects: partial.v1Projects ?? [], - v1Sections: partial.v1Sections ?? [], - v1Workspaces: partial.v1Workspaces ?? [], - }; -} - -describe("writeV2SidebarState", () => { - test("empty input writes nothing", () => { - const c = makeCollections(); - writeV2SidebarState(c.collections, buildInput({})); - expect(c.v2SidebarProjects._values()).toHaveLength(0); - expect(c.v2SidebarSections._values()).toHaveLength(0); - expect(c.v2WorkspaceLocalState._values()).toHaveLength(0); - }); - - test("migrated projects get sidebar entries with tabOrder + defaultApp", () => { - const c = makeCollections(); - writeV2SidebarState( - c.collections, - buildInput({ - projectV1ToV2: new Map([ - ["v1-p1", "v2-p1"], - ["v1-p2", "v2-p2"], - ]), - v1Projects: [project("v1-p1", 2, "cursor"), project("v1-p2", 5, null)], - }), - ); - const values = c.v2SidebarProjects._values(); - expect(values).toHaveLength(2); - const p1 = values.find((v) => v.projectId === "v2-p1"); - expect(p1?.tabOrder).toBe(2); - expect(p1?.defaultOpenInApp).toBe("cursor"); - const p2 = values.find((v) => v.projectId === "v2-p2"); - expect(p2?.tabOrder).toBe(5); - expect(p2?.defaultOpenInApp).toBe(null); - }); - - test("null tabOrder on v1 project falls back to 0", () => { - const c = makeCollections(); - writeV2SidebarState( - c.collections, - buildInput({ - projectV1ToV2: new Map([["v1", "v2"]]), - v1Projects: [project("v1", null)], - }), - ); - expect(c.v2SidebarProjects._values()[0]?.tabOrder).toBe(0); - }); - - test("empty v1 section under a migrated project still migrates", () => { - const c = makeCollections(); - writeV2SidebarState( - c.collections, - buildInput({ - projectV1ToV2: new Map([["v1-p", "v2-p"]]), - v1Projects: [project("v1-p")], - v1Sections: [ - section("sec-empty", "v1-p", 0, { - name: "Empty Group", - color: "#ff0000", - }), - ], - }), - ); - const sections = c.v2SidebarSections._values(); - expect(sections).toHaveLength(1); - expect(sections[0]?.name).toBe("Empty Group"); - expect(sections[0]?.projectId).toBe("v2-p"); - expect(sections[0]?.color).toBe("#ff0000"); - }); - - test("sections under un-migrated projects are skipped", () => { - const c = makeCollections(); - writeV2SidebarState( - c.collections, - buildInput({ - projectV1ToV2: new Map([["v1-p1", "v2-p1"]]), - v1Projects: [project("v1-p1")], - v1Sections: [ - section("sec-a", "v1-p1", 0), - section("sec-b", "v1-untracked", 0), - ], - }), - ); - const sections = c.v2SidebarSections._values(); - expect(sections).toHaveLength(1); - expect(sections[0]?.projectId).toBe("v2-p1"); - }); - - test("workspace sectionId matches the v2 section id (deterministic from v1)", () => { - const c = makeCollections(); - writeV2SidebarState( - c.collections, - buildInput({ - projectV1ToV2: new Map([["v1-p", "v2-p"]]), - workspaceV1ToV2: new Map([["v1-w", "v2-w"]]), - v1Projects: [project("v1-p")], - v1Sections: [section("v1-sec", "v1-p", 0)], - v1Workspaces: [workspace("v1-w", "v1-p", 0, "v1-sec")], - }), - ); - const ws = c.v2WorkspaceLocalState._values()[0]; - const sec = c.v2SidebarSections._values()[0]; - expect(ws?.sidebarState.sectionId).toBe(sec?.sectionId ?? "missing"); - // Reuses v1 id so reruns don't duplicate sections - expect(sec?.sectionId).toBe("v1-sec"); - }); - - test("rerun does not duplicate sections (idempotency)", () => { - const c = makeCollections(); - const input = buildInput({ - projectV1ToV2: new Map([["v1-p", "v2-p"]]), - v1Projects: [project("v1-p")], - v1Sections: [section("s1", "v1-p", 0), section("s2", "v1-p", 1)], - }); - writeV2SidebarState(c.collections, input); - writeV2SidebarState(c.collections, input); - writeV2SidebarState(c.collections, input); - expect(c.v2SidebarSections._values()).toHaveLength(2); - }); - - test("workspace pointing to non-existent v1 section ends up at top level", () => { - const c = makeCollections(); - writeV2SidebarState( - c.collections, - buildInput({ - projectV1ToV2: new Map([["v1-p", "v2-p"]]), - workspaceV1ToV2: new Map([["v1-w", "v2-w"]]), - v1Projects: [project("v1-p")], - v1Sections: [], - v1Workspaces: [workspace("v1-w", "v1-p", 0, "v1-sec-missing")], - }), - ); - const ws = c.v2WorkspaceLocalState._values()[0]; - expect(ws?.sidebarState.sectionId).toBe(null); - }); - - test("only adopted workspaces (in workspaceV1ToV2) get sidebar entries", () => { - const c = makeCollections(); - writeV2SidebarState( - c.collections, - buildInput({ - projectV1ToV2: new Map([["v1-p", "v2-p"]]), - workspaceV1ToV2: new Map([["v1-w1", "v2-w1"]]), - v1Projects: [project("v1-p")], - v1Workspaces: [ - workspace("v1-w1", "v1-p", 0), - workspace("v1-w2", "v1-p", 1), // not adopted - ], - }), - ); - const values = c.v2WorkspaceLocalState._values(); - expect(values).toHaveLength(1); - expect(values[0]?.workspaceId).toBe("v2-w1"); - }); - - test("workspace under un-migrated project is skipped", () => { - const c = makeCollections(); - writeV2SidebarState( - c.collections, - buildInput({ - projectV1ToV2: new Map([["v1-p-ok", "v2-p-ok"]]), - workspaceV1ToV2: new Map([ - ["v1-w-ok", "v2-w-ok"], - ["v1-w-orphan", "v2-w-orphan"], - ]), - v1Projects: [project("v1-p-ok"), project("v1-p-missing")], - v1Workspaces: [ - workspace("v1-w-ok", "v1-p-ok", 0), - workspace("v1-w-orphan", "v1-p-missing", 0), - ], - }), - ); - const values = c.v2WorkspaceLocalState._values(); - expect(values).toHaveLength(1); - expect(values[0]?.workspaceId).toBe("v2-w-ok"); - }); - - test("tab orders apply the normalization rules", () => { - // v1: [Section A (0), Workspace X (1), Section B (2), Workspace Y (3)] - const c = makeCollections(); - writeV2SidebarState( - c.collections, - buildInput({ - projectV1ToV2: new Map([["p", "v2-p"]]), - workspaceV1ToV2: new Map([ - ["X", "v2-X"], - ["Y", "v2-Y"], - ]), - v1Projects: [project("p")], - v1Sections: [section("A", "p", 0), section("B", "p", 2)], - v1Workspaces: [workspace("X", "p", 1), workspace("Y", "p", 3)], - }), - ); - const sections = c.v2SidebarSections._values(); - const workspaces = c.v2WorkspaceLocalState._values(); - // Top-level workspaces normalize to 0, 1; sections follow at 2, 3. - const xState = workspaces.find((w) => w.workspaceId === "v2-X"); - const yState = workspaces.find((w) => w.workspaceId === "v2-Y"); - expect(xState?.sidebarState.tabOrder).toBe(0); - expect(yState?.sidebarState.tabOrder).toBe(1); - const aSec = sections.find((s) => s.name === "section-A"); - const bSec = sections.find((s) => s.name === "section-B"); - expect(aSec?.tabOrder).toBe(2); - expect(bSec?.tabOrder).toBe(3); - }); - - test("idempotent: re-running with same input does not duplicate entries", () => { - const c = makeCollections(); - const input = buildInput({ - projectV1ToV2: new Map([["v1-p", "v2-p"]]), - workspaceV1ToV2: new Map([["v1-w", "v2-w"]]), - v1Projects: [project("v1-p")], - v1Sections: [section("sec", "v1-p", 0)], - v1Workspaces: [workspace("v1-w", "v1-p", 0)], - }); - writeV2SidebarState(c.collections, input); - writeV2SidebarState(c.collections, input); - expect(c.v2SidebarProjects._values()).toHaveLength(1); - // Note: section UUID is generated fresh per call; idempotency for sections - // relies on the outer migration calling writeV2SidebarState exactly once. - // Workspace entries re-use the same workspaceV1ToV2 mapping so they dedup. - expect(c.v2WorkspaceLocalState._values()).toHaveLength(1); - }); - - test("workspaces inside a section preserve within-section order", () => { - const c = makeCollections(); - writeV2SidebarState( - c.collections, - buildInput({ - projectV1ToV2: new Map([["p", "v2-p"]]), - workspaceV1ToV2: new Map([ - ["w1", "v2-w1"], - ["w2", "v2-w2"], - ["w3", "v2-w3"], - ]), - v1Projects: [project("p")], - v1Sections: [section("sec", "p", 0)], - v1Workspaces: [ - workspace("w1", "p", 7, "sec"), - workspace("w2", "p", 1, "sec"), - workspace("w3", "p", 4, "sec"), - ], - }), - ); - const workspaces = c.v2WorkspaceLocalState._values(); - const byId = new Map(workspaces.map((w) => [w.workspaceId, w])); - // w2 (v1 tabOrder=1) should come first, then w3 (4), then w1 (7) - expect(byId.get("v2-w2")?.sidebarState.tabOrder).toBe(0); - expect(byId.get("v2-w3")?.sidebarState.tabOrder).toBe(1); - expect(byId.get("v2-w1")?.sidebarState.tabOrder).toBe(2); - }); -}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.ts deleted file mode 100644 index 0084fa02784..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { WorkspaceState } from "@superset/panes"; -import type { OrgCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; -import { computeNormalizedOrders } from "./normalize"; - -const EMPTY_PANE_LAYOUT = { - version: 1, - tabs: [], - activeTabId: null, -} satisfies WorkspaceState; - -/** - * v1 project row shape consumed by sidebar translation. Structural so callers - * can pass drizzle rows without coupling. - */ -export interface V1ProjectLike { - id: string; - tabOrder: number | null; - defaultApp: string | null; -} - -export interface V1SectionLike { - id: string; - projectId: string; - name: string; - tabOrder: number; - isCollapsed: boolean | null; - color: string | null; -} - -export interface V1WorkspaceLike { - id: string; - projectId: string; - sectionId: string | null; - tabOrder: number; -} - -export interface SidebarInput { - /** v1 project id → v2 project id, for projects that successfully migrated. */ - projectV1ToV2: Map; - /** v1 workspace id → v2 workspace id, for workspaces that successfully adopted. */ - workspaceV1ToV2: Map; - v1Projects: V1ProjectLike[]; - v1Sections: V1SectionLike[]; - v1Workspaces: V1WorkspaceLike[]; -} - -/** - * Translates v1 sidebar state (project order, sections, workspace order + - * section membership) into the three v2 collections that back the dashboard - * sidebar. Single entry point so the main migration loop only deals with - * cloud/host-service creates; all renderer-side collection writes live here. - * - * Tab orders are normalized via computeNormalizedOrders so top-level - * workspaces always sort before sections in v2 (v2 absorbs post-section - * top-level workspaces into the preceding section at render time — see - * useDashboardSidebarData.ts:343-357). - * - * Idempotent: each write checks collection.get(id) first, so rerunning over - * an already-populated sidebar is a no-op. - */ -export function writeV2SidebarState( - collections: OrgCollections, - input: SidebarInput, -): void { - const { workspaceTabOrder, sectionTabOrder } = computeNormalizedOrders({ - workspaces: input.v1Workspaces.map((w) => ({ - id: w.id, - projectId: w.projectId, - sectionId: w.sectionId, - tabOrder: w.tabOrder, - })), - sections: input.v1Sections.map((s) => ({ - id: s.id, - projectId: s.projectId, - tabOrder: s.tabOrder, - })), - }); - - // 1. Projects: write per-project sidebar meta (pin order + default app). - const v1ProjectsById = new Map(input.v1Projects.map((p) => [p.id, p])); - for (const [v1ProjectId, v2ProjectId] of input.projectV1ToV2) { - if (collections.v2SidebarProjects.get(v2ProjectId)) continue; - const v1Project = v1ProjectsById.get(v1ProjectId); - collections.v2SidebarProjects.insert({ - projectId: v2ProjectId, - createdAt: new Date(), - isCollapsed: false, - tabOrder: v1Project?.tabOrder ?? 0, - defaultOpenInApp: v1Project?.defaultApp ?? null, - }); - } - - // 2. Sections: create v2 sections for every v1 section under a migrated - // project. Reuse the v1 section id (already a UUID) as the v2 section - // id — deterministic mapping makes reruns idempotent and lets the - // `get(id)` guard actually dedup. Empty sections are preserved — v1 - // supports them as an organizational primitive and the user may have - // intentionally created one ahead of filling it. - const sectionV1ToV2 = new Map(); - for (const v1Section of input.v1Sections) { - const v2ProjectId = input.projectV1ToV2.get(v1Section.projectId); - if (!v2ProjectId) continue; - const v2SectionId = v1Section.id; - sectionV1ToV2.set(v1Section.id, v2SectionId); - if (collections.v2SidebarSections.get(v2SectionId)) continue; - collections.v2SidebarSections.insert({ - sectionId: v2SectionId, - projectId: v2ProjectId, - name: v1Section.name, - createdAt: new Date(), - tabOrder: sectionTabOrder.get(v1Section.id) ?? v1Section.tabOrder, - isCollapsed: v1Section.isCollapsed ?? false, - color: v1Section.color ?? null, - }); - } - - // 3. Workspaces: per-workspace sidebar state (tab order + section - // membership + empty pane layout). Only adopted workspaces are - // included — skipped/errored workspaces have no v2 counterpart. - const v1WorkspacesById = new Map(input.v1Workspaces.map((w) => [w.id, w])); - for (const [v1WorkspaceId, v2WorkspaceId] of input.workspaceV1ToV2) { - if (collections.v2WorkspaceLocalState.get(v2WorkspaceId)) continue; - const v1Workspace = v1WorkspacesById.get(v1WorkspaceId); - if (!v1Workspace) continue; - const v2ProjectId = input.projectV1ToV2.get(v1Workspace.projectId); - if (!v2ProjectId) continue; - const v2SectionId = v1Workspace.sectionId - ? (sectionV1ToV2.get(v1Workspace.sectionId) ?? null) - : null; - collections.v2WorkspaceLocalState.insert({ - workspaceId: v2WorkspaceId, - createdAt: new Date(), - sidebarState: { - projectId: v2ProjectId, - tabOrder: workspaceTabOrder.get(v1WorkspaceId) ?? v1Workspace.tabOrder, - sectionId: v2SectionId, - changesFilter: { kind: "all" }, - }, - paneLayout: EMPTY_PANE_LAYOUT, - viewedFiles: [], - recentlyViewedFiles: [], - }); - } -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2/index.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2/index.ts deleted file mode 100644 index 9a6522c4a0c..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useMigrateV1PresetsToV2 } from "./useMigrateV1PresetsToV2"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2/useMigrateV1PresetsToV2.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2/useMigrateV1PresetsToV2.ts deleted file mode 100644 index 81eb7e7905f..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2/useMigrateV1PresetsToV2.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useEffect, useRef } from "react"; -import { env } from "renderer/env.renderer"; -import { authClient } from "renderer/lib/auth-client"; -import { electronTrpcClient } from "renderer/lib/trpc-client"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { MOCK_ORG_ID } from "shared/constants"; - -function getMigrationMarkerKey(organizationId: string): string { - return `v2-terminal-presets-migrated-${organizationId}`; -} - -/** - * Copies v1 main-process terminal presets into the v2TerminalPresets - * collection on first run per organization. v1's `getTerminalPresets` - * auto-initializes default agent presets on first call, so fresh users - * get a populated bar and users who customized v1 keep their presets. - * - * Uses the vanilla electronTrpcClient (ipcLink) instead of the React - * hook because V2PresetsBar is mounted inside WorkspaceTrpcProvider, - * which would route the request to the workspace HTTP server (404). - */ -export function useMigrateV1PresetsToV2() { - const collections = useCollections(); - const { data: session } = authClient.useSession(); - const organizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : session?.session?.activeOrganizationId; - const migratedOrgRef = useRef(null); - - useEffect(() => { - if (!organizationId) return; - if (migratedOrgRef.current === organizationId) return; - - const markerKey = getMigrationMarkerKey(organizationId); - if (localStorage.getItem(markerKey) === "1") { - migratedOrgRef.current = organizationId; - return; - } - - migratedOrgRef.current = organizationId; - - void (async () => { - try { - const v1Presets = - await electronTrpcClient.settings.getTerminalPresets.query(); - - const now = new Date(); - collections.v2TerminalPresets.insert( - v1Presets.map((v1Preset, index) => ({ - id: crypto.randomUUID(), - name: v1Preset.name, - description: v1Preset.description, - cwd: v1Preset.cwd, - commands: v1Preset.commands, - projectIds: v1Preset.projectIds ?? null, - pinnedToBar: v1Preset.pinnedToBar, - applyOnWorkspaceCreated: v1Preset.applyOnWorkspaceCreated, - applyOnNewTab: v1Preset.applyOnNewTab, - executionMode: v1Preset.executionMode ?? "new-tab", - tabOrder: index, - createdAt: now, - })), - ); - - localStorage.setItem(markerKey, "1"); - } catch { - migratedOrgRef.current = null; - } - })(); - }, [collections.v2TerminalPresets, organizationId]); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 1e73a8a3705..8e46ae08a95 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -25,7 +25,6 @@ import { showWorkspaceAutoNameWarningToast } from "renderer/lib/workspaces/showW import { LanguageServicesProvider } from "renderer/providers/LanguageServicesProvider"; import { InitGitDialog } from "renderer/react-query/projects/InitGitDialog"; import { DashboardNewWorkspaceModal } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal"; -import { V1MigrationSummaryModal } from "renderer/routes/_authenticated/components/V1MigrationSummaryModal"; import { GitOperationDialog } from "renderer/screens/main/components/GitOperationDialog"; import { WorkspaceInitEffects } from "renderer/screens/main/components/WorkspaceInitEffects"; import { @@ -239,7 +238,6 @@ function AuthenticatedLayout() { - {isV2CloudEnabled ? ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/index.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/index.ts index 53b72976220..28138c82fdc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/index.ts @@ -1,2 +1,3 @@ export * from "./schema"; export * from "./sidebarVisibility"; +export * from "./tabOrder"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/tabOrder.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/tabOrder.ts new file mode 100644 index 00000000000..c68a579ffd7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/tabOrder.ts @@ -0,0 +1,22 @@ +/** + * Lower tabOrder = appears earlier in the sidebar (queries sort ASC). + * Prepending therefore picks one less than the smallest existing tabOrder + * so the new item lands at the top regardless of whether existing items + * are positive (newly defaulted) or negative (after prior prepends). + */ +export function getPrependTabOrder(items: Array<{ tabOrder: number }>): number { + if (items.length === 0) return 1; + const minTabOrder = items.reduce( + (minValue, item) => Math.min(minValue, item.tabOrder), + Number.POSITIVE_INFINITY, + ); + return minTabOrder - 1; +} + +export function getNextTabOrder(items: Array<{ tabOrder: number }>): number { + const maxTabOrder = items.reduce( + (maxValue, item) => Math.max(maxValue, item.tabOrder), + 0, + ); + return maxTabOrder + 1; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.tsx index d8995df38c1..40e5152481d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.tsx @@ -11,25 +11,12 @@ import { } from "@superset/ui/alert-dialog"; import { Button } from "@superset/ui/button"; import { Label } from "@superset/ui/label"; -import { toast } from "@superset/ui/sonner"; import { Switch } from "@superset/ui/switch"; import { useNavigate } from "@tanstack/react-router"; -import { formatDistanceToNow } from "date-fns"; -import { useEffect, useState } from "react"; -import { LuRefreshCw } from "react-icons/lu"; -import { env } from "renderer/env.renderer"; import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { track } from "renderer/lib/analytics"; -import { authClient } from "renderer/lib/auth-client"; -import { - readLastMigrationRunAt, - useMigrateV1DataToV2, - V1_MIGRATION_LAST_RUN_AT_EVENT, -} from "renderer/routes/_authenticated/hooks/useMigrateV1DataToV2"; -import type { MigrationSummary } from "renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate"; import { STEP_ROUTES, useOnboardingStore } from "renderer/stores/onboarding"; import { useV2LocalOverrideStore } from "renderer/stores/v2-local-override"; -import { MOCK_ORG_ID } from "shared/constants"; import { isItemVisible, SETTING_ITEM_ID, @@ -47,19 +34,13 @@ export function ExperimentalSettings({ SETTING_ITEM_ID.EXPERIMENTAL_SUPERSET_V2, visibleItems, ); - const showV1Migration = isItemVisible( - SETTING_ITEM_ID.EXPERIMENTAL_V1_MIGRATION, - visibleItems, - ); const showRestartOnboarding = isItemVisible( SETTING_ITEM_ID.EXPERIMENTAL_RESTART_ONBOARDING, visibleItems, ); const { isV2CloudEnabled, isRemoteV2Enabled } = useIsV2CloudEnabled(); - const { rerun, isRunning } = useMigrateV1DataToV2({ autoRun: false }); const setOptInV2 = useV2LocalOverrideStore((state) => state.setOptInV2); const resetOnboarding = useOnboardingStore((state) => state.reset); - const lastRunAt = useLastMigrationRunAt(); const navigate = useNavigate(); function handleRestartOnboarding() { @@ -67,20 +48,6 @@ export function ExperimentalSettings({ void navigate({ to: STEP_ROUTES.providers }); } - async function rerunMigration() { - const result = await rerun(); - if (!result.completed) throw new Error(result.reason); - return result.summary; - } - - function handleRerunMigration() { - toast.promise(rerunMigration(), { - loading: "Running migration...", - success: (summary) => formatMigrationSuccess(summary), - error: (err) => `Migration run failed: ${errorMessage(err)}`, - }); - } - return (
@@ -120,42 +87,6 @@ export function ExperimentalSettings({ />
)} - {showV1Migration && ( -
-
- -

- Imports your local v1 projects and workspaces into the v2 cloud. - Runs automatically on launch — use this to retry if something - was missed. -

- {!isV2CloudEnabled ? ( -

- Available when v2 is enabled. -

- ) : lastRunAt !== null ? ( -

- Last run {formatDistanceToNow(lastRunAt, { addSuffix: true })} - . -

- ) : null} -
- -
- )} {showRestartOnboarding && (
@@ -206,85 +137,3 @@ export function ExperimentalSettings({
); } - -function useLastMigrationRunAt(): number | null { - const { data: session } = authClient.useSession(); - const organizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); - const [lastRunAt, setLastRunAt] = useState(null); - const [, forceTick] = useState(0); - - useEffect(() => { - if (!organizationId) { - setLastRunAt(null); - return; - } - setLastRunAt(readLastMigrationRunAt(organizationId)); - const onUpdate = (event: Event) => { - const detail = (event as CustomEvent<{ organizationId?: string }>).detail; - if (detail?.organizationId === organizationId) { - setLastRunAt(readLastMigrationRunAt(organizationId)); - } - }; - window.addEventListener(V1_MIGRATION_LAST_RUN_AT_EVENT, onUpdate); - // Re-render once a minute so "1 minute ago" advances to "2 minutes ago" - // without requiring a navigation. - const interval = window.setInterval(() => forceTick((t) => t + 1), 60_000); - return () => { - window.removeEventListener(V1_MIGRATION_LAST_RUN_AT_EVENT, onUpdate); - window.clearInterval(interval); - }; - }, [organizationId]); - - return lastRunAt; -} - -function errorMessage(err: unknown): string { - if (err instanceof Error) return err.message; - return String(err); -} - -function formatMigrationSuccess(summary: MigrationSummary): string { - const changed = - summary.projectsCreated + - summary.projectsLinked + - summary.projectsErrored + - summary.workspacesCreated + - summary.workspacesSkipped + - summary.workspacesErrored; - if (summary.errors.length > 0) { - const first = summary.errors[0]; - const successful = - summary.projectsCreated + - summary.projectsLinked + - summary.workspacesCreated + - summary.workspacesSkipped; - const successSuffix = - successful > 0 - ? ` (${successful} item${successful === 1 ? "" : "s"} completed or skipped)` - : ""; - return `Migration completed with ${summary.errors.length} error${ - summary.errors.length === 1 ? "" : "s" - }${successSuffix}: ${first.name}: ${first.message}`; - } - if ( - summary.projectsCreated + summary.projectsLinked === 0 && - summary.workspacesCreated === 0 && - summary.workspacesSkipped > 0 - ) { - return `Migration run completed: ${summary.workspacesSkipped} workspace${ - summary.workspacesSkipped === 1 ? "" : "s" - } skipped`; - } - if (changed === 0) return "Migration run completed: nothing to update"; - const skippedSuffix = - summary.workspacesSkipped > 0 - ? ` (+${summary.workspacesSkipped} skipped)` - : ""; - return `Migration run completed: ${summary.projectsCreated + summary.projectsLinked} project${ - summary.projectsCreated + summary.projectsLinked === 1 ? "" : "s" - }, ${summary.workspacesCreated} workspace${ - summary.workspacesCreated === 1 ? "" : "s" - }${skippedSuffix}`; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/V2PresetsSection/V2PresetsSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/V2PresetsSection/V2PresetsSection.tsx index df5235af68b..252ebd136c0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/V2PresetsSection/V2PresetsSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/V2PresetsSection/V2PresetsSection.tsx @@ -9,7 +9,6 @@ import { useLiveQuery } from "@tanstack/react-db"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { HiOutlinePlus } from "react-icons/hi2"; import { useIsDarkTheme } from "renderer/assets/app-icons/preset-icons"; -import { useMigrateV1PresetsToV2 } from "renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; import type { PresetColumnKey } from "renderer/routes/_authenticated/settings/presets/types"; @@ -48,7 +47,6 @@ export function V2PresetsSection({ }: V2PresetsSectionProps) { const isDark = useIsDarkTheme(); const collections = useCollections(); - useMigrateV1PresetsToV2(); const { data: v2Presets = [] } = useLiveQuery( (query) => diff --git a/apps/desktop/src/renderer/stores/v2-local-override.ts b/apps/desktop/src/renderer/stores/v2-local-override.ts index e2386b37a24..53cbc193a71 100644 --- a/apps/desktop/src/renderer/stores/v2-local-override.ts +++ b/apps/desktop/src/renderer/stores/v2-local-override.ts @@ -2,19 +2,24 @@ import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; interface V2LocalOverrideState { - /** When true, the user has opted into v2. v2 is gated behind both the remote flag and this opt-in. */ - optInV2: boolean; + /** + * When `true`, the user has opted into v2. v2 is gated behind both the + * remote flag and this opt-in. `null` means the resolver hasn't yet run + * on this install (fresh launch). + */ + optInV2: boolean | null; + setOptInV2: (optInV2: boolean) => void; + /** FORK: convenience toggle used by the experimental settings UI. */ toggle: () => void; - setOptInV2: (value: boolean) => void; } export const useV2LocalOverrideStore = create()( devtools( persist( (set, get) => ({ - optInV2: false, - toggle: () => set({ optInV2: !get().optInV2 }), - setOptInV2: (value: boolean) => set({ optInV2: value }), + optInV2: null, + setOptInV2: (optInV2) => set({ optInV2 }), + toggle: () => set({ optInV2: !(get().optInV2 === true) }), }), { name: "v2-local-override-v2" }, ), diff --git a/apps/desktop/src/renderer/stores/workspace-creates/store.ts b/apps/desktop/src/renderer/stores/workspace-creates/store.ts index 3842407936f..70b8d6f6719 100644 --- a/apps/desktop/src/renderer/stores/workspace-creates/store.ts +++ b/apps/desktop/src/renderer/stores/workspace-creates/store.ts @@ -1,3 +1,4 @@ +import type { SelectV2Workspace } from "@superset/db/schema"; import type { AppRouter } from "@superset/host-service"; import type { inferRouterInputs } from "@trpc/server"; import { create } from "zustand"; @@ -11,6 +12,13 @@ export interface InFlightEntry { state: "creating" | "error"; error?: string; startedAt: number; + /** + * Cloud row returned by the host-service mutation. Set once + * `workspaces.create` resolves successfully — the workspace detail + * layout falls back to this row while Electric hasn't yet delivered + * the synced row into `collections.v2Workspaces`. + */ + cloudRow?: SelectV2Workspace; } interface WorkspaceCreatesState { @@ -18,6 +26,7 @@ interface WorkspaceCreatesState { add: (entry: Omit) => void; markError: (workspaceId: string, error: string) => void; markCreating: (workspaceId: string) => void; + markCloudRow: (workspaceId: string, cloudRow: SelectV2Workspace) => void; remove: (workspaceId: string) => void; } @@ -44,6 +53,12 @@ export const useWorkspaceCreatesStore = create( : entry, ), })), + markCloudRow: (workspaceId, cloudRow) => + set((state) => ({ + entries: state.entries.map((entry) => + entry.snapshot.id === workspaceId ? { ...entry, cloudRow } : entry, + ), + })), remove: (workspaceId) => set((state) => ({ entries: state.entries.filter( diff --git a/apps/desktop/src/renderer/stores/workspace-creates/useWorkspaceCreates.ts b/apps/desktop/src/renderer/stores/workspace-creates/useWorkspaceCreates.ts index 556a1c3e127..9235053a72a 100644 --- a/apps/desktop/src/renderer/stores/workspace-creates/useWorkspaceCreates.ts +++ b/apps/desktop/src/renderer/stores/workspace-creates/useWorkspaceCreates.ts @@ -5,6 +5,10 @@ import { authClient } from "renderer/lib/auth-client"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { + getPrependTabOrder, + isSidebarWorkspaceVisible, +} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { appendLaunchesToPaneLayout } from "./appendLaunchesToPaneLayout"; import { @@ -62,6 +66,14 @@ export function useWorkspaceCreates(): UseWorkspaceCreatesApi { const client = getHostServiceClientByUrl(hostUrl); const result = await client.workspaces.create.mutate(args.snapshot); + // Cache the cloud row on the in-flight entry so the workspace + // detail layout can render the workspace immediately, without + // waiting for Electric to deliver the synced row. Manager.tsx + // removes the entry once the row appears in collections. + useWorkspaceCreatesStore + .getState() + .markCloudRow(result.workspace.id, result.workspace); + const existing = collections.v2WorkspaceLocalState.get( result.workspace.id, ); @@ -80,12 +92,26 @@ export function useWorkspaceCreates(): UseWorkspaceCreatesApi { }, ); } else { + const projectId = result.workspace.projectId; + const topLevelItems = [ + ...Array.from(collections.v2WorkspaceLocalState.state.values()) + .filter( + (item) => + item.sidebarState.projectId === projectId && + item.sidebarState.sectionId === null && + isSidebarWorkspaceVisible(item), + ) + .map((item) => ({ tabOrder: item.sidebarState.tabOrder })), + ...Array.from(collections.v2SidebarSections.state.values()) + .filter((item) => item.projectId === projectId) + .map((item) => ({ tabOrder: item.tabOrder })), + ]; collections.v2WorkspaceLocalState.insert({ workspaceId: result.workspace.id, createdAt: new Date(), sidebarState: { - projectId: result.workspace.projectId, - tabOrder: 0, + projectId, + tabOrder: getPrependTabOrder(topLevelItems), sectionId: null, changesFilter: { kind: "all" }, activeTab: "changes", diff --git a/bun.lock b/bun.lock index cd6a4c5432b..9a9e93f7187 100644 --- a/bun.lock +++ b/bun.lock @@ -6529,8 +6529,6 @@ "@ai-sdk/provider-utils-v5/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], - "@ai-sdk/react/react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], - "@ai-sdk/togetherai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], "@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-60GYsRj5wIJQRcq5YwYJq4KhwLeStceXEJiZdecP1miiH+6FMmrnc7lZDOJoQ6m9lrudEb+uI4LEwddLz5+rPQ=="], @@ -6701,8 +6699,6 @@ "@graphql-tools/graphql-file-loader/globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], - "@graphql-tools/graphql-tag-pluck/@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], - "@graphql-tools/import/@graphql-tools/utils": ["@graphql-tools/utils@11.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag=="], "@graphql-tools/json-file-loader/@graphql-tools/utils": ["@graphql-tools/utils@11.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag=="], diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts index 9052501ac9f..ac90bcf3cad 100644 --- a/packages/host-service/src/trpc/router/project/project.ts +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -1,13 +1,18 @@ import { rmSync } from "node:fs"; import { basename, resolve as resolvePath } from "node:path"; -import { parseGitHubRemote } from "@superset/shared/github-remote"; +import { + type ParsedGitHubRemote, + parseGitHubRemote, +} from "@superset/shared/github-remote"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; +import simpleGit from "simple-git"; import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; import { protectedProcedure, router } from "../../index"; import { createFromClone, createFromImportLocal } from "./handlers"; import { ensureMainWorkspace } from "./utils/ensure-main-workspace"; +import { getGitHubRemotes } from "./utils/git-remote"; import { persistLocalProject } from "./utils/persist-project"; import { cloneRepoInto, @@ -54,30 +59,238 @@ export const projectRouter = router({ }), findByPath: protectedProcedure - .input(z.object({ repoPath: z.string().min(1) })) + .input( + z.object({ + repoPath: z.string().min(1), + /** + * Opt-in to the v1→v2 importer's discovery semantics: walk + * every GitHub remote on the repo (not just origin/first), + * try `expectedRemoteUrl` against cloud, and surface stale + * local-DB rows. Default `false` preserves the long-standing + * folder-first import behavior — local-DB hit short-circuits + * before any cloud call, and only the primary remote is + * cloud-queried. + */ + walkAllRemotes: z.boolean().optional(), + /** + * Hint about the remote URL the caller *thinks* this project + * tracks (e.g. v1's recorded githubOwner). Only consulted + * when `walkAllRemotes` is true. + */ + expectedRemoteUrl: z.string().optional(), + }), + ) .query(async ({ ctx, input }) => { const resolved = await resolveLocalRepo(input.repoPath); + const gitRoot = resolved.repoPath; + + const expectedParsed = + input.walkAllRemotes && input.expectedRemoteUrl + ? parseGitHubRemote(input.expectedRemoteUrl) + : null; + const expectedUrlLower = expectedParsed?.url.toLowerCase(); + const matches = (cloneUrl: string | null) => + !!expectedUrlLower && + !!cloneUrl && + cloneUrl.toLowerCase() === expectedUrlLower; + + interface Candidate { + id: string; + name: string; + repoCloneUrl: string | null; + source: "local-path" | "remote"; + matchesExpected: boolean; + /** True when the cloud-URL loop returned this id, which means + * it's reachable in cloud — lets us skip the per-id v2Project.get + * staleness check. Internal; not part of the wire response. */ + cloudConfirmed: boolean; + /** True when this v2 project is no longer reachable in cloud + * (e.g. deleted) but a stale row still lives in this device's + * local DB. Caller-side filter drops these. */ + staleLocalLink: boolean; + } + const localProject = ctx.db.query.projects - .findFirst({ where: eq(projects.repoPath, resolved.repoPath) }) + .findFirst({ where: eq(projects.repoPath, gitRoot) }) .sync(); + + // Default behavior (folder-first import): local-DB hit wins, + // otherwise one cloud query against origin/first. Preserves the + // pre-importer-rewrite contract. + if (!input.walkAllRemotes) { + if (localProject) { + return { + candidates: [ + { + id: localProject.id, + name: localProject.repoName ?? basename(gitRoot), + repoCloneUrl: localProject.repoUrl ?? null, + source: "local-path" as const, + matchesExpected: false, + staleLocalLink: false, + }, + ], + cloudErrors: [] as { url: string; message: string }[], + }; + } + const { parsed } = resolved; + if (!parsed) return { candidates: [], cloudErrors: [] }; + try { + const { candidates } = + await ctx.api.v2Project.findByGitHubRemote.query({ + organizationId: ctx.organizationId, + repoCloneUrl: parsed.url, + }); + return { + candidates: candidates.map((c) => ({ + id: c.id, + name: c.name, + repoCloneUrl: parsed.url, + source: "remote" as const, + matchesExpected: false, + staleLocalLink: false, + })), + cloudErrors: [] as { url: string; message: string }[], + }; + } catch (err) { + return { + candidates: [], + cloudErrors: [ + { + url: parsed.url, + message: err instanceof Error ? err.message : String(err), + }, + ], + }; + } + } + + // walkAllRemotes branch — v1→v2 importer. + const allRemotes = await getGitHubRemotes(simpleGit(gitRoot)); + + const urlsToQuery = new Map(); + for (const parsed of allRemotes.values()) { + urlsToQuery.set(parsed.url.toLowerCase(), parsed); + } + if (expectedParsed) { + urlsToQuery.set(expectedParsed.url.toLowerCase(), expectedParsed); + } + + const byId = new Map(); + + if (localProject) { + byId.set(localProject.id, { + id: localProject.id, + name: localProject.repoName ?? basename(gitRoot), + repoCloneUrl: localProject.repoUrl ?? null, + source: "local-path", + matchesExpected: matches(localProject.repoUrl ?? null), + cloudConfirmed: false, + staleLocalLink: false, + }); + } + + // Cloud lookup for every URL we know about. + const cloudErrors: { url: string; message: string }[] = []; + for (const parsed of urlsToQuery.values()) { + try { + const { candidates } = + await ctx.api.v2Project.findByGitHubRemote.query({ + organizationId: ctx.organizationId, + repoCloneUrl: parsed.url, + }); + for (const c of candidates) { + const existing = byId.get(c.id); + if (existing) { + // Already have it from local-DB lookup; the cloud + // confirms it's reachable, so keep `local-path` + // source but populate matchesExpected if needed + // and flip `cloudConfirmed` so we skip the post- + // loop staleness round-trip. + existing.matchesExpected = + existing.matchesExpected || matches(parsed.url); + existing.repoCloneUrl = existing.repoCloneUrl ?? parsed.url; + existing.cloudConfirmed = true; + } else { + byId.set(c.id, { + id: c.id, + name: c.name, + repoCloneUrl: parsed.url, + source: "remote", + matchesExpected: matches(parsed.url), + cloudConfirmed: true, + staleLocalLink: false, + }); + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + cloudErrors.push({ url: parsed.url, message }); + console.warn( + "[project.findByPath] cloud findByGitHubRemote failed for", + parsed.url, + err, + ); + } + } + + // Detect stale local-DB row: returned by the path lookup but + // cloud never confirmed it via any remote URL. Most likely the + // cloud project was deleted by another device or user. Skip + // when the cloud loop already saw this id (cloudConfirmed) — + // no need for a second round-trip. if (localProject) { - return { - candidates: [ - { + const candidate = byId.get(localProject.id); + if ( + candidate && + candidate.source === "local-path" && + !candidate.cloudConfirmed + ) { + try { + await ctx.api.v2Project.get.query({ + organizationId: ctx.organizationId, id: localProject.id, - name: localProject.repoName ?? basename(resolved.repoPath), - }, - ], - }; + }); + } catch (err) { + // Only treat a confirmed not-found as stale. Transient + // network/auth/5xx errors should leave the local link + // intact and surface via cloudErrors instead, so we + // don't drop a probably-still-valid candidate on a + // blip. + const code = + typeof err === "object" && err !== null + ? ((err as { data?: { code?: string } }).data?.code ?? null) + : null; + if (code === "NOT_FOUND") { + candidate.staleLocalLink = true; + } else { + cloudErrors.push({ + url: `v2Project.get(${localProject.id})`, + message: err instanceof Error ? err.message : String(err), + }); + } + } + } } - const { parsed } = resolved; - if (!parsed) return { candidates: [] }; - const { candidates } = await ctx.api.v2Project.findByGitHubRemote.query({ - organizationId: ctx.organizationId, - repoCloneUrl: parsed.url, - }); - return { candidates }; + // Sort: matchesExpected first, then alphabetic. Strip the + // internal `cloudConfirmed` flag — it's a server-side + // optimization, not part of the wire contract. + const candidates = Array.from(byId.values()) + .filter((c) => !c.staleLocalLink) + .sort((a, b) => { + if (a.matchesExpected !== b.matchesExpected) { + return a.matchesExpected ? -1 : 1; + } + return a.name.localeCompare(b.name); + }) + .map(({ cloudConfirmed: _omit, ...rest }) => rest); + + // Caller surfaces this when there are no candidates and at least + // one cloud query failed — so users see a clear "couldn't reach + // cloud" instead of a misleading "Import" (which would create a + // duplicate v2 project). + return { candidates, cloudErrors }; }), create: protectedProcedure diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/index.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/index.ts index 518e2bb9cb7..f3e277263bc 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/index.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/index.ts @@ -1,4 +1,5 @@ export { adopt } from "./adopt"; +export { listProjectWorktrees } from "./list-project-worktrees"; export { searchBranches } from "./search-branches"; export { searchGitHubIssues } from "./search-github-issues"; export { searchPullRequests } from "./search-pull-requests"; diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/list-project-worktrees.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/list-project-worktrees.ts new file mode 100644 index 00000000000..4eafc39fae5 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/list-project-worktrees.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { protectedProcedure } from "../../../index"; +import { requireLocalProject } from "../shared/local-project"; +import { listGitWorktrees } from "../shared/worktree-list"; + +/** + * Returns the live `git worktree list` for a project — only entries that + * are valid adoption targets (have a real branch checked out, not bare, + * not prunable). Used by the v1→v2 importer to filter out v1 workspaces + * whose worktree no longer exists on disk before showing them as + * importable rows. + */ +export const listProjectWorktrees = protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { + const localProject = requireLocalProject(ctx, input.projectId); + const git = await ctx.git(localProject.repoPath); + const records = await listGitWorktrees(git); + const worktrees: { branch: string; path: string }[] = []; + for (const record of records) { + if (record.bare) continue; + if (record.prunable) continue; + if (!record.branch) continue; + worktrees.push({ branch: record.branch, path: record.path }); + } + return { worktrees }; + }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/worktree-list.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/worktree-list.ts new file mode 100644 index 00000000000..cad6c53c961 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/worktree-list.ts @@ -0,0 +1,94 @@ +import { realpathSync } from "node:fs"; +import { resolve as resolvePath } from "node:path"; +import type { GitClient } from "./types"; + +// Single source of truth for parsing `git worktree list --porcelain`. +// Every consumer in this package MUST go through `parseWorktreeList` / +// `listGitWorktrees` instead of re-parsing the porcelain output inline. +// Inline parsers drift apart silently — that is exactly how the +// "missing worktrees" bug crept in: one parser gated by managed-root +// prefix, another by realpath, and they disagreed. + +export type WorktreeRecord = { + // Path as git reports it (already realpath-canonicalized by git). + path: string; + // HEAD sha, or null for a bare worktree. + head: string | null; + // Branch short name (without `refs/heads/`), or null when detached or bare. + branch: string | null; + detached: boolean; + bare: boolean; + // `locked` and `prunable` carry a reason string when present (possibly + // empty), or null when the flag isn't set on this worktree. + locked: { reason: string } | null; + prunable: { reason: string } | null; +}; + +export function parseWorktreeList(raw: string): WorktreeRecord[] { + const records: WorktreeRecord[] = []; + let current: WorktreeRecord | null = null; + const flush = () => { + if (current) { + records.push(current); + current = null; + } + }; + for (const line of raw.split("\n")) { + if (line.startsWith("worktree ")) { + flush(); + current = { + path: line.slice("worktree ".length).trim(), + head: null, + branch: null, + detached: false, + bare: false, + locked: null, + prunable: null, + }; + } else if (!current) { + // Stray line before the first `worktree` block — ignore. + } else if (line.startsWith("HEAD ")) { + current.head = line.slice("HEAD ".length).trim() || null; + } else if (line.startsWith("branch ")) { + const ref = line.slice("branch ".length).trim(); + current.branch = ref.startsWith("refs/heads/") + ? ref.slice("refs/heads/".length) + : ref; + } else if (line === "detached") { + current.detached = true; + } else if (line === "bare") { + current.bare = true; + } else if (line === "locked" || line.startsWith("locked ")) { + current.locked = { reason: line.slice("locked".length).trim() }; + } else if (line === "prunable" || line.startsWith("prunable ")) { + current.prunable = { reason: line.slice("prunable".length).trim() }; + } else if (line === "") { + flush(); + } + } + flush(); + return records; +} + +export async function listGitWorktrees( + git: GitClient, +): Promise { + try { + const raw = await git.raw(["worktree", "list", "--porcelain"]); + return parseWorktreeList(raw); + } catch (err) { + console.warn("[workspace-creation] git worktree list failed:", err); + return []; + } +} + +// Resolves a filesystem path through realpath when possible. Used to +// compare paths from git (which it canonicalizes) against caller-supplied +// paths (which may still contain symlinks like macOS `/var` → `/private/var`). +export function normalizeWorktreePath(path: string): string { + try { + return realpathSync.native(path); + } catch { + return resolvePath(path); + } +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts index 75b4b6fda65..0c5872ffd87 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts @@ -1,6 +1,7 @@ import { router } from "../../index"; import { adopt, + listProjectWorktrees, searchBranches, searchGitHubIssues, searchPullRequests, @@ -9,6 +10,7 @@ import { export const workspaceCreationRouter = router({ searchBranches, adopt, + listProjectWorktrees, searchGitHubIssues, searchPullRequests, }); diff --git a/packages/host-service/src/trpc/router/workspace/workspace.ts b/packages/host-service/src/trpc/router/workspace/workspace.ts index 25421fef23e..7d21165ff2e 100644 --- a/packages/host-service/src/trpc/router/workspace/workspace.ts +++ b/packages/host-service/src/trpc/router/workspace/workspace.ts @@ -23,6 +23,18 @@ export const workspaceRouter = router({ return localWorkspace; }), + cloudList: protectedProcedure.query(async ({ ctx }) => { + const rows = await ctx.api.v2Workspace.list.query({ + organizationId: ctx.organizationId, + }); + return rows.map((row) => ({ + id: row.id, + projectId: row.projectId, + branch: row.branch, + hostId: row.hostId, + })); + }), + gitStatus: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { diff --git a/packages/host-service/src/trpc/router/workspaces/workspaces.ts b/packages/host-service/src/trpc/router/workspaces/workspaces.ts index 0339a1b623d..5c1f48867c0 100644 --- a/packages/host-service/src/trpc/router/workspaces/workspaces.ts +++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts @@ -73,18 +73,17 @@ type AgentLaunchResult = | ({ ok: true } & AgentRunResult) | { ok: false; error: string }; -interface ResolvedWorkspace { - id: string; - projectId: string; - name: string; - branch: string; -} +type CloudWorkspace = NonNullable< + Awaited< + ReturnType + > +>; async function findExistingWorkspaceByBranch( ctx: HostServiceContext, projectId: string, branch: string, -): Promise { +): Promise { const local = ctx.db.query.workspaces .findFirst({ where: and( @@ -99,13 +98,7 @@ async function findExistingWorkspaceByBranch( organizationId: ctx.organizationId, id: local.id, }); - if (!cloud) return null; - return { - id: cloud.id, - projectId: cloud.projectId, - name: cloud.name, - branch: cloud.branch, - }; + return cloud ?? null; } interface PrMetadata { @@ -380,7 +373,7 @@ async function registerCloudAndLocal(args: { taskId: string | undefined; rollbackWorktree: () => Promise; hostPromise: Promise<{ machineId: string }>; -}): Promise<{ id: string; projectId: string; name: string; branch: string }> { +}): Promise { const { ctx } = args; let host: { machineId: string }; try { @@ -442,12 +435,7 @@ async function registerCloudAndLocal(args: { }); } - return { - id: cloudRow.id, - projectId: cloudRow.projectId, - name: cloudRow.name, - branch: cloudRow.branch, - }; + return cloudRow; } async function dispatchSugarAgents( @@ -516,12 +504,7 @@ export const workspacesRouter = router({ let resolvedBranch: string; let worktreePath: string; let alreadyExists = false; - let workspaceRow: { - id: string; - projectId: string; - name: string; - branch: string; - }; + let workspaceRow: CloudWorkspace; let prMetadata: PrMetadata | null = null; if (input.pr !== undefined) { @@ -807,12 +790,7 @@ export const workspacesRouter = router({ ); return { - workspace: { - id: workspaceRow.id, - projectId: workspaceRow.projectId, - name: workspaceRow.name, - branch: workspaceRow.branch, - }, + workspace: workspaceRow, terminals: terminalsResult, agents: agentsResult, alreadyExists, diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index 5e8ae7aae3c..8c72bebc223 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -290,7 +290,7 @@ export const settings = sqliteTable("settings", { export type InsertSettings = typeof settings.$inferInsert; export type SelectSettings = typeof settings.$inferSelect; -export type V1MigrationKind = "project" | "workspace"; +export type V1MigrationKind = "project" | "workspace" | "preset"; export type V1MigrationStatus = "success" | "linked" | "error" | "skipped"; export const v1MigrationState = sqliteTable( diff --git a/packages/sdk/src/resources/workspaces.ts b/packages/sdk/src/resources/workspaces.ts index 006dfb0f758..fb29d323bf8 100644 --- a/packages/sdk/src/resources/workspaces.ts +++ b/packages/sdk/src/resources/workspaces.ts @@ -163,9 +163,16 @@ export type WorkspaceCreateAgentResult = export interface WorkspaceCreateResult { workspace: { id: string; + organizationId: string; projectId: string; + hostId: string; name: string; branch: string; + type: "main" | "worktree"; + createdByUserId: string | null; + taskId: string | null; + createdAt: Date; + updatedAt: Date; }; terminals: Array<{ terminalId: string; label?: string }>; agents: WorkspaceCreateAgentResult[]; diff --git a/packages/shared/package.json b/packages/shared/package.json index 8aab16c310b..2447896c166 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -92,6 +92,10 @@ "types": "./src/host-routing.ts", "default": "./src/host-routing.ts" }, + "./host-version": { + "types": "./src/host-version.ts", + "default": "./src/host-version.ts" + }, "./github-remote": { "types": "./src/github-remote.ts", "default": "./src/github-remote.ts" diff --git a/packages/shared/src/host-version.ts b/packages/shared/src/host-version.ts new file mode 100644 index 00000000000..b028017228f --- /dev/null +++ b/packages/shared/src/host-version.ts @@ -0,0 +1,29 @@ +/** + * Minimum host-service version this app can work with. Bumping this forces + * the desktop coordinator to kill + respawn any adopted local service older + * than this, and gates v2 workspace UIs from mounting against a remote host + * whose CLI is still on an older version. + * + * 0.4.0: terminal launch moved from `terminal.ensureSession` to + * `terminal.launchSession` plus WebSocket attach params. + * 0.3.0: host-service registers via cloud `host.ensure` (was + * `device.ensureV2Host`); v2_hosts/v2_users_hosts/v2_workspaces use + * machineId text instead of uuid surrogates. + * 0.2.0: `workspaceCreation.adopt` gained optional `worktreePath`. + * + * 0.5.0 — pty-daemon supervision migrated into host-service. New + * `terminal.daemon` tRPC namespace; older 0.4.x host-services don't + * expose it. Adopting one in place would leave the new desktop + * talking to old code: Settings → Manage daemon would silently + * fail, and the v2 PTY survival promise is broken. + * + * 0.7.0 — canonical `workspaces.create` flow + `settings.hostAgentConfigs` + * router (PR1, #3893). Older 0.6.x host-services don't expose either, + * so adopting one in place would break new-project creation and the + * agent-config settings UI. + * + * 0.8.0 — v2 terminal creation moved to `terminal.createSession`; the + * WebSocket route is attach-only by `terminalId`. Older host-services would + * reject the renderer's creation call and still expect socket-side startup. + */ +export const MIN_HOST_SERVICE_VERSION = "0.8.0"; diff --git a/packages/trpc/src/router/v2-workspace/v2-workspace.ts b/packages/trpc/src/router/v2-workspace/v2-workspace.ts index 99e8f0de609..66661ebc896 100644 --- a/packages/trpc/src/router/v2-workspace/v2-workspace.ts +++ b/packages/trpc/src/router/v2-workspace/v2-workspace.ts @@ -1,6 +1,12 @@ -import { dbWs } from "@superset/db/client"; +import { db, dbWs } from "@superset/db/client"; import { v2WorkspaceTypeValues } from "@superset/db/enums"; -import { tasks, v2Hosts, v2Projects, v2Workspaces } from "@superset/db/schema"; +import { + tasks, + v2Hosts, + v2Projects, + v2UsersHosts, + v2Workspaces, +} from "@superset/db/schema"; import { getCurrentTxid } from "@superset/db/utils"; import type { TRPCRouterRecord } from "@trpc/server"; import { TRPCError } from "@trpc/server"; @@ -102,6 +108,57 @@ async function getWorkspaceAccess( } export const v2WorkspaceRouter = { + list: jwtProcedure + .input( + z.object({ + organizationId: z.string().uuid(), + hostId: z.string().min(1).optional(), + }), + ) + .query(async ({ ctx, input }) => { + if (!ctx.organizationIds.includes(input.organizationId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not a member of this organization", + }); + } + + const rows = await db + .select({ + id: v2Workspaces.id, + name: v2Workspaces.name, + branch: v2Workspaces.branch, + projectId: v2Workspaces.projectId, + projectName: v2Projects.name, + hostId: v2Workspaces.hostId, + }) + .from(v2Workspaces) + .innerJoin( + v2UsersHosts, + and( + eq(v2UsersHosts.organizationId, v2Workspaces.organizationId), + eq(v2UsersHosts.hostId, v2Workspaces.hostId), + ), + ) + .leftJoin(v2Projects, eq(v2Projects.id, v2Workspaces.projectId)) + .where( + and( + eq(v2Workspaces.organizationId, input.organizationId), + eq(v2UsersHosts.userId, ctx.userId), + input.hostId ? eq(v2Workspaces.hostId, input.hostId) : undefined, + ), + ); + + return rows.map((row) => ({ + id: row.id, + name: row.name, + branch: row.branch, + projectId: row.projectId, + projectName: row.projectName ?? "", + hostId: row.hostId, + })); + }), + create: jwtProcedure .input( z.object({ diff --git a/scripts/v1v2-import-test-cleanup.sh b/scripts/v1v2-import-test-cleanup.sh new file mode 100755 index 00000000000..3cd27e5a593 --- /dev/null +++ b/scripts/v1v2-import-test-cleanup.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Undoes everything v1v2-import-test-setup.sh did. Idempotent. + +set -euo pipefail + +SATYA_TEST_ORG=b2c3d4e5-f6a7-4890-9bcd-ef1234567891 +SUPERSET_ORG=a1b2c3d4-e5f6-7890-abcd-ef1234567890 +DEV_DATA_LOCAL_DB="$(pwd)/superset-dev-data/local.db" +SATYA_TEST_HOST_DB="$(pwd)/superset-dev-data/host/$SATYA_TEST_ORG/host.db" +SUPERSET_HOST_DB="$(pwd)/superset-dev-data/host/$SUPERSET_ORG/host.db" + +NEW_NO_REMOTE_ID=22222222-bbbb-4bbb-8bbb-000000000001 +NEW_GHOST_ID=22222222-bbbb-4bbb-8bbb-000000000002 + +echo "→ removing on-disk fixture repos" +if [ -d "$HOME/code/onbook-relocate-clone/.git" ]; then + for branch in v1v2-test-clean v1v2-test-stale-fallback v1v2-test-ghost; do + git -C "$HOME/code/onbook-relocate-clone" worktree remove -f \ + ".worktrees/$branch" 2>/dev/null || true + git -C "$HOME/code/onbook-relocate-clone" branch -D "$branch" 2>/dev/null || true + done +fi +rm -rf "$HOME/code/onbook-relocate-clone" \ + "$HOME/code/v1v2-no-remote" \ + "$HOME/code/v1v2-ghost" + +echo "→ removing fakeupstream remote from onlook" +git -C "$HOME/code/onlook" remote remove fakeupstream 2>/dev/null || true + +echo "→ restoring v1 onbook mainRepoPath + clearing fixtures we added" +sqlite3 "$DEV_DATA_LOCAL_DB" </dev/null + +echo "✓ cleanup done" diff --git a/scripts/v1v2-import-test-setup.sh b/scripts/v1v2-import-test-setup.sh new file mode 100755 index 00000000000..c259ab98471 --- /dev/null +++ b/scripts/v1v2-import-test-setup.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# Set up the manual test fixtures for the v1→v2 importer. +# +# Idempotent — safe to re-run. Pair with v1v2-import-test-cleanup.sh. +# +# Targets: +# - Dev neon branch via .env DATABASE_URL (must be a non-prod branch) +# - superset-dev-data/local.db (v1 fixtures) +# - superset-dev-data/host//host.db (host.db fixtures) +# - ~/code/ (on-disk fixture repos) +# +# v1 local DB project rows that already exist (superset, cal.com, onbook, +# onlook, mastra, chatbot) are NOT recreated — we just bump their tab_order +# so they show up in the importer. New synthetic rows (v1v2-no-remote, +# v1v2-ghost) are inserted with id prefix 22222222-bbbb-... + +set -euo pipefail + +SATYA_TEST_ORG=b2c3d4e5-f6a7-4890-9bcd-ef1234567891 +SUPERSET_ORG=a1b2c3d4-e5f6-7890-abcd-ef1234567890 + +DEV_DATA="$(pwd)/superset-dev-data" +DEV_DATA_LOCAL_DB="$DEV_DATA/local.db" +SATYA_TEST_HOST_DB="$DEV_DATA/host/$SATYA_TEST_ORG/host.db" +SUPERSET_HOST_DB="$DEV_DATA/host/$SUPERSET_ORG/host.db" + +NEW_NO_REMOTE_ID=22222222-bbbb-4bbb-8bbb-000000000001 +NEW_GHOST_ID=22222222-bbbb-4bbb-8bbb-000000000002 + +ONBOOK_V1_ID=098e54ad-7160-497f-aae3-57c68c8b6a8e +ONBOOK_FIXTURE_V2_ID=33333333-aaaa-4aaa-8aaa-000000000004 + +# ---- 0. sanity checks -------------------------------------------------------- + +if [ ! -f "$DEV_DATA_LOCAL_DB" ]; then + echo "✗ $DEV_DATA_LOCAL_DB missing — run the dev build at least once first." + exit 1 +fi + +PGURL=$(grep '^DATABASE_URL=' .env | sed 's/^DATABASE_URL=//;s/^"//;s/"$//') +BRANCH_NAME=$(grep '^NEON_BRANCH_ID=' .env | sed 's/^NEON_BRANCH_ID=//;s/^"//;s/"$//') +if [ -z "$BRANCH_NAME" ] || [ "$BRANCH_NAME" = "br-billowing-dream-af839yib" ]; then + echo "✗ refusing to seed — .env DATABASE_URL points at the prod neon branch." + echo " Spin up a dev branch and update .env first." + exit 1 +fi + +echo "→ targeting neon branch $BRANCH_NAME" + +# ---- 1. on-disk fixture repos ----------------------------------------------- + +echo "→ adding fakeupstream remote to ~/code/onlook" +git -C "$HOME/code/onlook" remote remove fakeupstream 2>/dev/null || true +git -C "$HOME/code/onlook" remote add fakeupstream \ + https://github.com/satya-fake-org/onlook.git + +echo "→ cloning onbook → ~/code/onbook-relocate-clone" +rm -rf "$HOME/code/onbook-relocate-clone" +git clone --quiet --depth 1 "$HOME/code/onbook" "$HOME/code/onbook-relocate-clone" +git -C "$HOME/code/onbook-relocate-clone" remote set-url origin \ + https://github.com/onlook-dev/onbook.git + +echo "→ creating ~/code/v1v2-no-remote (local-only fixture)" +rm -rf "$HOME/code/v1v2-no-remote" +mkdir -p "$HOME/code/v1v2-no-remote" +( + cd "$HOME/code/v1v2-no-remote" + git init -q -b main + echo "# v1v2-no-remote — local-only fixture" > README.md + git -c user.email=test@superset.sh -c user.name=Test add README.md + git -c user.email=test@superset.sh -c user.name=Test commit -q -m init +) + +echo "→ creating ~/code/v1v2-ghost (single-remote fixture)" +rm -rf "$HOME/code/v1v2-ghost" +mkdir -p "$HOME/code/v1v2-ghost" +( + cd "$HOME/code/v1v2-ghost" + git init -q -b main + git remote add origin https://github.com/satya-fake-org/v1v2-ghost.git + echo "# v1v2-ghost fixture" > README.md + git -c user.email=test@superset.sh -c user.name=Test add README.md + git -c user.email=test@superset.sh -c user.name=Test commit -q -m init +) + +echo "→ adding worktrees to ~/code/onbook-relocate-clone" +for branch in v1v2-test-clean v1v2-test-stale-fallback v1v2-test-ghost; do + git -C "$HOME/code/onbook-relocate-clone" worktree remove -f \ + ".worktrees/$branch" 2>/dev/null || true + git -C "$HOME/code/onbook-relocate-clone" branch -D "$branch" 2>/dev/null || true + git -C "$HOME/code/onbook-relocate-clone" worktree add -q \ + ".worktrees/$branch" -b "$branch" +done + +# ---- 2. cloud v2 fixtures (Satya Test Org) ---------------------------------- + +echo "→ seeding v2 projects in Satya Test Org" +psql "$PGURL" >/dev/null </dev/null < branch fallback', 101, strftime('%s','now')*1000, strftime('%s','now')*1000, strftime('%s','now')*1000), + ('55555555-dddd-4ddd-8ddd-000000000003', '$ONBOOK_V1_ID', '44444444-cccc-4ccc-8ccc-000000000003', 'worktree', 'v1v2-test-orphan-branch', 'orphan (should be hidden)', 102, strftime('%s','now')*1000, strftime('%s','now')*1000, strftime('%s','now')*1000), + ('55555555-dddd-4ddd-8ddd-000000000004', '$ONBOOK_V1_ID', '44444444-cccc-4ccc-8ccc-000000000004', 'worktree', 'v1v2-test-ghost', 'v1v2-test-ghost', 103, strftime('%s','now')*1000, strftime('%s','now')*1000, strftime('%s','now')*1000) +ON CONFLICT (id) DO UPDATE SET + worktree_id = excluded.worktree_id, + branch = excluded.branch, + name = excluded.name; +SQL + +# ---- 5. host.db row for relocate scenario ----------------------------------- + +echo "→ inserting host.db relocate row (Superset org)" +sqlite3 "$SUPERSET_HOST_DB" <