mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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)
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.
This commit is contained in:
+8
-3
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
// "<repo> (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)
|
||||
})
|
||||
})
|
||||
@@ -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 "<name> (cloud)" server whose rows live on
|
||||
// `runtime:<environmentId>`. 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:<environmentId>`. 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<AppState, 'runtimeEnvironments'>,
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, { id: string; hostId?: string; isMainWorktree?: boolean }>()
|
||||
)
|
||||
const owned = vi.hoisted(() => new Set<string>())
|
||||
|
||||
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<unknown>) {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<string[]> {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user