From 0fd98c5c2abe40ddeaa0b32bf2f8b0774ea081ce Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:26:49 -0800 Subject: [PATCH] fix(cloud): PAIRED-SLEEP sleep a paired cloud workspace's sandbox through the control plane; paired-from-birth rows are cloud rows from the catalog mark (#565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sleep on a paired cloud workspace's own row (no local VM record) now stops its sandbox via cloudWorkspaces.sleep(sandboxId), so it stops billing; failures reuse the cloud sleep toast. useIsCloudHostRow / isCloudPrimaryRowNow also read the runtime catalog's orcaCloudPaired mark, so a GitHub-only create loses its "primary" badge and gets "Delete workspace…" and Sleep without waiting for the pairing bindings to load. --- .../use-worktree-context-menu-model.tsx | 11 ++- .../sleep/sleep-worktree-flow.ts | 18 ++--- .../lib/orca-cloud/cloud-host-rows.test.ts | 64 ++++++++++++++++ .../src/lib/orca-cloud/cloud-host-rows.ts | 37 +++++++++- .../orca-cloud/cloud-sleep-failure-toast.ts | 4 +- .../lib/orca-cloud/paired-cloud-sleep.test.ts | 73 +++++++++++++++++++ .../src/lib/orca-cloud/paired-cloud-sleep.ts | 48 ++++++++++++ 7 files changed, 238 insertions(+), 17 deletions(-) create mode 100644 src/renderer/src/lib/orca-cloud/cloud-host-rows.test.ts create mode 100644 src/renderer/src/lib/orca-cloud/paired-cloud-sleep.test.ts create mode 100644 src/renderer/src/lib/orca-cloud/paired-cloud-sleep.ts diff --git a/src/renderer/src/components/sidebar/workspace-actions/context-menu/use-worktree-context-menu-model.tsx b/src/renderer/src/components/sidebar/workspace-actions/context-menu/use-worktree-context-menu-model.tsx index 7848363aacc..fc090f95d42 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/context-menu/use-worktree-context-menu-model.tsx +++ b/src/renderer/src/components/sidebar/workspace-actions/context-menu/use-worktree-context-menu-model.tsx @@ -1,4 +1,5 @@ import { selectAwakeBrowserTabsByWorktree } from '@/components/sidebar/workspace-actions/sleep/browser-tab-sleep' +import { useIsPairedCloudPrimaryRow } from '@/lib/orca-cloud/cloud-host-rows' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store/index' import { useRepoById } from '@/store/repos/use-repository-catalog' @@ -153,12 +154,16 @@ export function useWorktreeContextMenuModel({ unifiedTabsByWorktree ] ) + // A paired cloud workspace can be running with nothing open here: its Sleep reaches the sandbox. + const pairedCloudSleepable = useIsPairedCloudPrimaryRow(worktree) && !isMultiContext const sleepableWorktrees = useMemo( () => - activeContextWorktrees.filter((item) => - hasSleepableWorkspaceActivity(item.id, sleepActivity) + activeContextWorktrees.filter( + (item) => + hasSleepableWorkspaceActivity(item.id, sleepActivity) || + (pairedCloudSleepable && item.id === worktree.id) ), - [activeContextWorktrees, sleepActivity] + [activeContextWorktrees, sleepActivity, pairedCloudSleepable, worktree.id] ) const lineageMenuActions = useWorkspaceLineageMenuActions({ enabled: menuOpen && !isMultiContext, diff --git a/src/renderer/src/components/sidebar/workspace-actions/sleep/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/workspace-actions/sleep/sleep-worktree-flow.ts index 2475eac6710..e6d6ac3fd31 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/sleep/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/workspace-actions/sleep/sleep-worktree-flow.ts @@ -1,5 +1,6 @@ import { toast } from 'sonner' import { toastCloudSleepFailure } from '@/lib/orca-cloud/cloud-sleep-failure-toast' +import { sleepPairedCloudSandboxes } from '@/lib/orca-cloud/paired-cloud-sleep' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import type { ExecutionHostId } from '../../../../../../shared/execution-host' @@ -95,16 +96,15 @@ export async function runSleepWorktrees( for (const id of unsleptWorktreeIds) { releaseWorktreeSleepView(id) } + // A paired cloud workspace's sandbox sleeps through the control plane (PAIRED-SLEEP). + const unslept = new Set(unsleptWorktreeIds) + const pairedStillRunning = await sleepPairedCloudSandboxes( + worktreeIds.filter((id) => !unslept.has(id)) + ) + const stillRunningIds = [...vmStillRunningIds, ...pairedStillRunning] // An Orca Cloud sandbox left running gets the cloud copy (sandbox hours, Try again) instead. - const cloudToasted = toastCloudSleepFailure( - vmStillRunningIds, - (ids) => void runSleepWorktrees(ids) - ) - toastSleepFailures( - new Set(worktreeIds).size, - errors, - vmStillRunningIds.length > 0 && !cloudToasted - ) + const cloudToasted = toastCloudSleepFailure(stillRunningIds, (ids) => void runSleepWorktrees(ids)) + toastSleepFailures(new Set(worktreeIds).size, errors, stillRunningIds.length > 0 && !cloudToasted) // A slept Orca Cloud sandbox won't restart, so its restart warning no longer applies. for (const result of results) { if (result.completed) { diff --git a/src/renderer/src/lib/orca-cloud/cloud-host-rows.test.ts b/src/renderer/src/lib/orca-cloud/cloud-host-rows.test.ts new file mode 100644 index 00000000000..46aa7395c94 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-host-rows.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment happy-dom +// A cloud workspace created from a GitHub-only repo is paired from birth: its row sits on a +// " (cloud)" server on the sandbox's own checkout ("master · primary"). The runtime catalog's +// paired mark alone must make it a cloud row (no "primary" badge, "Delete workspace…", Sleep reaches +// the sandbox), before anything has loaded the pairing bindings. +import { renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +type CatalogEntry = { + id: string + name: string + source?: 'manual' | 'ephemeral-vm' + orcaCloudPaired?: true +} +const state = vi.hoisted(() => { + const value: { runtimeEnvironments: CatalogEntry[] } = { runtimeEnvironments: [] } + return value +}) +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (value: typeof state) => unknown) => selector(state), { + getState: () => state + }) +})) + +import { + isCloudPrimaryRowNow, + useIsCloudHostRow, + useIsCloudPrimaryRow, + useIsPairedCloudPrimaryRow +} from './cloud-host-rows' + +const fromGitHub = { + id: 'push-test::/workspace/orca-sandbox-push-test', + hostId: 'runtime:env-gh', + isMainWorktree: true +} + +afterEach(() => { + state.runtimeEnvironments = [] +}) + +describe('a paired-from-birth cloud row (GitHub-only create)', () => { + it('is a cloud primary row from the catalog mark alone: no primary badge, Delete workspace…', () => { + state.runtimeEnvironments = [ + { id: 'env-gh', name: 'orca-sandbox-push-test (cloud)', orcaCloudPaired: true } + ] + expect(renderHook(() => useIsCloudHostRow(fromGitHub)).result.current).toBe(true) + expect(renderHook(() => useIsCloudPrimaryRow(fromGitHub)).result.current).toBe(true) + expect(renderHook(() => useIsPairedCloudPrimaryRow(fromGitHub)).result.current).toBe(true) + expect(isCloudPrimaryRowNow(fromGitHub)).toBe(true) + }) + + it('the same row on an ordinary paired server is not a cloud row (upstream rules apply)', () => { + state.runtimeEnvironments = [{ id: 'env-gh', name: 'build box (cloud)', source: 'manual' }] + expect(renderHook(() => useIsCloudHostRow(fromGitHub)).result.current).toBe(false) + expect(isCloudPrimaryRowNow(fromGitHub)).toBe(false) + }) + + it('a local primary checkout is never a cloud row', () => { + const local = { id: 'repo::/path', hostId: 'local', isMainWorktree: true } + expect(renderHook(() => useIsCloudHostRow(local)).result.current).toBe(false) + expect(isCloudPrimaryRowNow(local)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-host-rows.ts b/src/renderer/src/lib/orca-cloud/cloud-host-rows.ts index 7366a431bba..2294d90e46a 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-host-rows.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-host-rows.ts @@ -1,8 +1,11 @@ // Which sidebar rows are Orca Cloud workspaces, including PAIRED ones (CLOUD-PRIMARY-DELETE). // `useIsCloudWorkspace` knows only the workspaces this desktop created. A workspace opened from Home // on another desktop (or after a re-sign-in) is paired: a " (cloud)" server whose rows live on -// `runtime:`. The cloud identity store already maps both kinds to their sandbox -// (this desktop's runtime records, and the pairing bindings), so this reads it. +// `runtime:`. Two sources, either is enough: +// - the runtime catalog: main marks a paired server `orcaCloudPaired` (PAIRED-AWARE), so +// `isOrcaCloudRuntimeEnvironment` knows it synchronously, with no priming (a row created from a +// GitHub-only repo is paired from birth and must lose its "primary" badge at once); +// - the cloud identity store, which also maps the row to its sandbox id (for Delete and Sleep). // // A cloud row on the sandbox's own checkout (isMainWorktree) IS the cloud workspace, not a local // repo's primary checkout: Delete must destroy the sandbox, never "Remove the project". @@ -13,6 +16,10 @@ import { useCloudWorkspaceIdentities } from '@/components/cloud-workspaces/cloud-workspace-identity' import { getCloudWorkspaceSnapshot, useIsCloudWorkspace } from './orca-cloud-workspace-store' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { isOrcaCloudRuntimeEnvironment } from '../../../../shared/remote-runtime/host-catalog/orca-cloud-environment-name' type Row = { id: string; hostId?: string | null; isMainWorktree?: boolean } @@ -28,11 +35,25 @@ export function usePrimeCloudRowBindings(): void { }, []) } +/** Is the row's host an Orca Cloud server in the runtime catalog (owned, or marked paired)? */ +function isCloudHostInCatalog( + state: Pick, + hostId: string | null | undefined +): boolean { + const parsed = parseExecutionHostId(hostId ?? undefined) + if (parsed?.kind !== 'runtime') { + return false + } + const environment = state.runtimeEnvironments?.find((entry) => entry.id === parsed.environmentId) + return environment ? isOrcaCloudRuntimeEnvironment(environment) : false +} + /** A row on an Orca Cloud workspace: this desktop's own, or one it paired with. */ export function useIsCloudHostRow(row: Row): boolean { const owned = useIsCloudWorkspace(row.id) const identities = useCloudWorkspaceIdentities() - return owned || sandboxIdForWorktree(row, identities) !== null + const inCatalog = useAppStore((state) => isCloudHostInCatalog(state, row.hostId)) + return owned || inCatalog || sandboxIdForWorktree(row, identities) !== null } /** The cloud workspace itself (the sandbox's own checkout): its Delete destroys the sandbox. */ @@ -40,11 +61,19 @@ export function useIsCloudPrimaryRow(row: Row): boolean { return useIsCloudHostRow(row) && row.isMainWorktree === true } +/** A cloud workspace's own row that this desktop did NOT create (paired): no local VM record. */ +export function useIsPairedCloudPrimaryRow(row: Row): boolean { + const owned = useIsCloudWorkspace(row.id) + return useIsCloudPrimaryRow(row) && !owned +} + /** Non-React: the same, for the delete funnel. */ export function isCloudPrimaryRowNow(row: Row): boolean { return ( row.isMainWorktree === true && - (getCloudWorkspaceSnapshot().ids.has(row.id) || sandboxIdForWorktree(row) !== null) + (getCloudWorkspaceSnapshot().ids.has(row.id) || + isCloudHostInCatalog(useAppStore.getState(), row.hostId) || + sandboxIdForWorktree(row) !== null) ) } diff --git a/src/renderer/src/lib/orca-cloud/cloud-sleep-failure-toast.ts b/src/renderer/src/lib/orca-cloud/cloud-sleep-failure-toast.ts index b7785f50be8..890e112750d 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-sleep-failure-toast.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-sleep-failure-toast.ts @@ -5,9 +5,11 @@ import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { findWorktreeById } from '@/store/github/refresh/worktree-refresh' import { getCloudWorkspaceSnapshot } from './orca-cloud-workspace-store' +import { isPairedCloudSleepTarget } from './paired-cloud-sleep' export function isCloudWorkspaceId(worktreeId: string): boolean { - return getCloudWorkspaceSnapshot().ids.has(worktreeId) + // Paired workspaces' sandboxes too: their failed Sleep leaves a sandbox running just the same. + return getCloudWorkspaceSnapshot().ids.has(worktreeId) || isPairedCloudSleepTarget(worktreeId) } function workspaceName(worktreeId: string): string { diff --git a/src/renderer/src/lib/orca-cloud/paired-cloud-sleep.test.ts b/src/renderer/src/lib/orca-cloud/paired-cloud-sleep.test.ts new file mode 100644 index 00000000000..1621c84de6e --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/paired-cloud-sleep.test.ts @@ -0,0 +1,73 @@ +// PAIRED-SLEEP: Sleep on a paired cloud workspace puts its sandbox to sleep through the control plane; +// this desktop's own sandboxes keep the ordinary Sleep (their VM suspends), local rows are untouched. +import { afterEach, describe, expect, it, vi } from 'vitest' + +const rows = vi.hoisted( + () => new Map() +) +const owned = vi.hoisted(() => new Set()) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) +vi.mock('@/store/workspaces/selection/worktree-on-host-selector', () => ({ + getWorktreeOnHostFromState: (_state: unknown, id: string) => rows.get(id) +})) +vi.mock('./orca-cloud-workspace-store', () => ({ + getCloudWorkspaceSnapshot: () => ({ ids: owned }), + useIsCloudWorkspace: () => false +})) + +import { isPairedCloudSleepTarget, sleepPairedCloudSandboxes } from './paired-cloud-sleep' +import { resetCloudWorkspaceIdentityForTests } from '@/components/cloud-workspaces/cloud-workspace-identity' + +function stub(sleep: (id: string) => Promise) { + const fn = vi.fn(sleep) + vi.stubGlobal('window', { + api: { + cloudWorkspaces: { + sleep: fn, + localWorkspaces: async () => [], + environmentBindings: async () => ({ sbx_paired: { environmentId: 'env-paired' } }) + } + } + }) + return fn +} + +afterEach(() => { + vi.unstubAllGlobals() + rows.clear() + owned.clear() + resetCloudWorkspaceIdentityForTests() +}) + +describe('PAIRED-SLEEP', () => { + it('a paired cloud workspace: its sandbox sleeps through the control plane', async () => { + rows.set('wt-paired', { id: 'wt-paired', hostId: 'runtime:env-paired', isMainWorktree: true }) + const sleep = stub(async () => ({ ok: true, value: undefined })) + await expect(sleepPairedCloudSandboxes(['wt-paired'])).resolves.toEqual([]) + expect(sleep).toHaveBeenCalledWith('sbx_paired') + expect(isPairedCloudSleepTarget('wt-paired')).toBe(true) + }) + + it('a refused sleep is reported: the sandbox is still running (cloud toast, Try again)', async () => { + rows.set('wt-paired', { id: 'wt-paired', hostId: 'runtime:env-paired', isMainWorktree: true }) + stub(async () => ({ ok: false, error: 'conflict' })) + await expect(sleepPairedCloudSandboxes(['wt-paired'])).resolves.toEqual(['wt-paired']) + }) + + it('this desktop’s own sandbox and a local workspace are left to the ordinary Sleep', async () => { + rows.set('wt-owned', { id: 'wt-owned', hostId: 'runtime:env-paired', isMainWorktree: true }) + owned.add('wt-owned') + rows.set('wt-local', { id: 'wt-local', hostId: 'local', isMainWorktree: true }) + const sleep = stub(async () => ({ ok: true, value: undefined })) + await expect(sleepPairedCloudSandboxes(['wt-owned', 'wt-local'])).resolves.toEqual([]) + expect(sleep).not.toHaveBeenCalled() + }) + + it('a non-primary worktree inside a paired sandbox does not sleep the whole sandbox', async () => { + rows.set('wt-other', { id: 'wt-other', hostId: 'runtime:env-paired', isMainWorktree: false }) + const sleep = stub(async () => ({ ok: true, value: undefined })) + await sleepPairedCloudSandboxes(['wt-other']) + expect(sleep).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/paired-cloud-sleep.ts b/src/renderer/src/lib/orca-cloud/paired-cloud-sleep.ts new file mode 100644 index 00000000000..ecebd4accea --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/paired-cloud-sleep.ts @@ -0,0 +1,48 @@ +// PAIRED-SLEEP (PAIRED-AWARE): Sleep on a paired cloud workspace (opened here, created elsewhere: a +// GitHub-only repo, another device, a teammate's, a link) must put its SANDBOX to sleep. This desktop +// has no runtime record of it, so the ordinary Sleep only closed panels here while the sandbox kept +// running and using sandbox hours. The control plane sleeps it, like Home's Sleep does. +import { useAppStore } from '@/store' +import { getWorktreeOnHostFromState } from '@/store/workspaces/selection/worktree-on-host-selector' +import { getCloudWorkspaceSnapshot } from './orca-cloud-workspace-store' +import { isCloudPrimaryRowNow, resolveCloudRowSandboxId } from './cloud-host-rows' +import { refreshCloudWorktreeBindings } from '@/components/cloud-workspaces/cloud-workspace-identity' + +type Row = { id: string; hostId?: string | null; isMainWorktree?: boolean } + +function rowFor(worktreeId: string): Row | null { + return getWorktreeOnHostFromState(useAppStore.getState(), worktreeId, undefined) ?? null +} + +/** A paired cloud workspace's own row: its Sleep must reach the sandbox through the control plane. */ +export function isPairedCloudSleepTarget(worktreeId: string): boolean { + if (getCloudWorkspaceSnapshot().ids.has(worktreeId)) { + // This desktop's own sandbox: the ordinary Sleep suspends its VM. + return false + } + const row = rowFor(worktreeId) + return row !== null && isCloudPrimaryRowNow(row) +} + +/** Sleeps each paired target's sandbox; returns the ids whose sandbox is still running. */ +export async function sleepPairedCloudSandboxes(worktreeIds: readonly string[]): Promise { + const stillRunning: string[] = [] + const api = typeof window !== 'undefined' ? window.api?.cloudWorkspaces : undefined + if (!api) { + return stillRunning + } + // Sleep can run before any menu loaded the pairings (card action, shortcut): read them first. + await refreshCloudWorktreeBindings().catch(() => undefined) + for (const id of worktreeIds) { + if (!isPairedCloudSleepTarget(id)) { + continue + } + const row = rowFor(id) + const sandboxId = row ? await resolveCloudRowSandboxId(row) : null + const result = sandboxId ? await api.sleep(sandboxId).catch(() => null) : null + if (!result?.ok) { + stillRunning.push(id) + } + } + return stillRunning +}