revert(mobile): drop server sleep and wake from the phone for now

The workspace-layout refactor deletes the notifier path these used; sleep and
wake on a server come back later as server-side layout.sleep / layout.wake.
A server row's Sleep behaves exactly as on feat/mobile-remote-workspaces.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-10-11 00:59:07 -04:00
parent ccec8b0a8a
commit dc7f7bced4
20 changed files with 51 additions and 515 deletions
@@ -189,7 +189,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
label: 'Sleep',
icon: Moon,
onPress: () => {
actions.sleepWorktree(actionTarget)
actions.putWorktreeToSleep(actionTarget)
state.setActionTarget(null)
}
},
@@ -176,7 +176,9 @@ async function mountScreen(first: FakeSession, hostCapabilities: string[]): Prom
}),
sleep: (worktreeId) =>
act(() => {
held.actions?.sleepWorktree(held.rows.find((entry) => entry.worktreeId === worktreeId)!)
held.actions?.putWorktreeToSleep(
held.rows.find((entry) => entry.worktreeId === worktreeId)!
)
}),
slept: () => held.state?.sleptIds,
serverWorkspaces: () => held.state?.serverWorkspaces,
@@ -319,41 +321,12 @@ describe("a desktop's server workspaces on the phone", () => {
expect(screen.rows().find((entry) => entry.worktreeId === 'runtime:vm-wt')?.isPinned).toBe(true)
})
it('sleeps a server workspace through the desktop, whose renderer runs the sleep', async () => {
it('sleeps a desktop workspace on the desktop and shows it slept at once', async () => {
const client = desktop()
const screen = await mountScreen(client, RELAYS)
screen.sleep('runtime:vm-wt')
screen.sleep('mac-wt')
const sleeps = client.sendRequest.mock.calls.filter(([method]) => method.includes('leep'))
expect(sleeps.map(([method, params]) => [method, params])).toEqual([
['mobileRelay.hosts.sleepWorktree', { hostId: 'runtime:vm', worktreeId: 'runtime:vm-wt' }],
['worktree.sleep', { worktree: 'id:mac-wt' }]
])
})
it('shows a server workspace slept until the server itself reports it so', async () => {
let live = 2
const client = desktop({
hostWorktrees: (hostId) => ({
worktrees: [{ ...row(`${hostId}-wt`, hostId), liveTerminalCount: live }],
stale: false,
fetchedAt: 1
})
})
const screen = await mountScreen(client, RELAYS)
const serverRow = () => screen.rows().find((entry) => entry.worktreeId === 'runtime:vm-wt')
expect(serverRow()?.liveTerminalCount).toBe(2)
screen.sleep('runtime:vm-wt')
await screen.poll()
// The desktop's own list never carries the server's row, so it cannot confirm or undo the sleep.
expect(serverRow()?.liveTerminalCount).toBe(0)
expect(client.sendRequest).toHaveBeenCalledWith('worktree.sleep', { worktree: 'id:mac-wt' })
expect(screen.slept()?.size).toBe(1)
live = 0
await screen.poll()
expect(screen.slept()?.size).toBe(0)
expect(serverRow()?.liveTerminalCount).toBe(0)
})
it('reads a replaced client\u2019s servers at once, and keeps an unchanged poll\u2019s list', async () => {
@@ -20,7 +20,6 @@ import {
removeWorktreeRow
} from '../worktree/worktree-host-row-identity'
import { isWorktreePinned, type Worktree } from '../worktree/workspace-list-sections'
import { relayHostWorktreeSleep } from '../worktree/server-workspace-operations'
import {
worktreeActivate,
worktreePinWrite,
@@ -160,21 +159,13 @@ export function useHostWorktreeActions(args: {
[client, clientForRow, worktrees, pinnedIds, updateLocalPins]
)
const sleepWorktree = useCallback(
const putWorktreeToSleep = useCallback(
(item: Worktree) => {
if (!client) {
return
}
setSleptIds((prev) => new Set(prev).add(getWorktreeRowIdentity(item)))
// Why: the desktop sleeps a server's workspace in its own renderer, as it does its own.
const serverHost = item.hostId
const sleep = serverHost?.startsWith('runtime:')
? relayHostWorktreeSleep.request(client, {
hostId: serverHost,
worktreeId: item.worktreeId
})
: worktreeSleep.request(client, { worktree: `id:${item.worktreeId}` })
void sleep.catch(() => null)
void worktreeSleep.request(client, { worktree: `id:${item.worktreeId}` }).catch(() => null)
},
[client, setSleptIds]
)
@@ -305,7 +296,7 @@ export function useHostWorktreeActions(args: {
openNewWorktreeModal,
openWorktreeSession,
setShowNewWorktreeVisible,
sleepWorktree,
putWorktreeToSleep,
togglePin
}
}
@@ -16,17 +16,12 @@ import { savePinnedIds } from '../storage/preferences'
import type { FetchHostRepoMetadata } from './use-host-repo-metadata'
import type { HostScreenState } from './use-host-screen-state'
import { relaysToServers } from '../transport/execution-host-scoped-rpc-client'
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import {
applyServerWorkspaces,
fetchServerWorkspaces,
NO_SERVER_WORKSPACES
} from '../worktree/server-workspaces'
function isServerHost(host: ExecutionHostId | undefined): boolean {
return host?.startsWith('runtime:') === true
}
export function useHostWorktreeCatalog(args: {
client: RpcClient | null
connState: ConnectionState
@@ -82,16 +77,6 @@ export function useHostWorktreeCatalog(args: {
const fetched = await fetchServerWorkspaces(requestClient)
if (fetched && clientRef.current === requestClient) {
setServerWorkspaces((previous) => applyServerWorkspaces(previous, fetched))
const read = new Set<ExecutionHostId>(
fetched.hosts.filter((_host, index) => fetched.rows[index]).map((host) => host.hostId)
)
setSleptIds((prev) =>
retainLiveSleptWorktreeIdentities(
prev,
fetched.rows.flatMap((rows) => rows ?? []),
(host) => host !== undefined && read.has(host)
)
)
}
} catch {
// Keeps the rows shown; the next poll retries.
@@ -101,7 +86,7 @@ export function useHostWorktreeCatalog(args: {
}
}
},
[hostCapabilities, setSleptIds]
[hostCapabilities]
)
const fetchWorktrees = useCallback(
@@ -164,9 +149,7 @@ export function useHostWorktreeCatalog(args: {
)
// Clear optimistic sleep overrides once the server confirms inactive (liveTerminalCount === 0).
setSleptIds((prev) =>
retainLiveSleptWorktreeIdentities(prev, confirmed, (host) => !isServerHost(host))
)
setSleptIds((prev) => retainLiveSleptWorktreeIdentities(prev, confirmed))
// Sync pin state from server so desktop-initiated pins reflect without relying on stale AsyncStorage.
const serverPinned = new Set(confirmed.filter((w) => w.isPinned).map((w) => w.worktreeId))
@@ -5,7 +5,6 @@ import { useRouteHandoff } from '../navigation/route-handoff'
import { HOST_DOCK_MIN_WIDTH } from '../storage/preferences'
import { useForceReconnect } from '../transport/client-context'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host'
import {
useLastConnectedAt,
useReconnectAttempt
@@ -39,8 +38,7 @@ export function useMobileSessionFoundation() {
const router = useRouteHandoff()
const insets = useSafeAreaInsets()
// Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md).
const { client, clientId, state: connState, desktopClient } = useWorkspaceClient(hostId)
const executionHost = useWorkspaceExecutionHost()
const { client, clientId, state: connState } = useWorkspaceClient(hostId)
const { hostCapabilities } = useHostProtocolGates()
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
@@ -104,8 +102,6 @@ export function useMobileSessionFoundation() {
client,
clientId,
connState,
desktopClient,
executionHost,
hostCapabilities,
reconnectAttempts,
lastConnectedAt,
@@ -1,113 +0,0 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
import { useMobileSessionStartup } from './use-mobile-session-startup'
const WORKTREE_ID = 'server-repo::/srv/orca'
function fakeClient(reply: (method: string) => unknown): RpcClient & {
sendRequest: ReturnType<typeof vi.fn>
} {
const sendRequest = vi.fn(async (method: string) => ({
id: method,
ok: true,
result: reply(method)
}))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook only sends requests.
return { sendRequest } as unknown as RpcClient & { sendRequest: ReturnType<typeof vi.fn> }
}
/** Opens a workspace's session route: on a server when `executionHost` is set. */
async function openSession(options: {
client: RpcClient
desktopClient: RpcClient
executionHost?: 'runtime:env-1'
}) {
const showToast = vi.fn()
function Session() {
useMobileSessionStartup(
mountFixture<Parameters<typeof useMobileSessionStartup>[0]>({
hostId: 'host-1',
worktreeId: WORKTREE_ID,
isFloatingWorkspaceRoute: false,
connState: 'connected',
client: options.client,
desktopClient: options.desktopClient,
executionHost: options.executionHost,
setTerminals: () => {},
terminalsRef: { current: [] },
setSessionTabs: () => {},
appliedSnapshotMarkerRef: { current: { epoch: null, version: -1 } },
closedTabTombstonesRef: { current: new Map() },
setTerminalsLoaded: () => {},
setActiveHandle: () => {},
setActiveSessionTabId: () => {},
setMarkdownDocs: () => {},
setFileDocs: () => {},
terminalGestureInputQueuesRef: { current: new Map() },
terminalGestureInputInFlightRef: { current: new Set() },
sessionTabActionSheetKeyboardHideSubRef: { current: null },
sessionTabActionSheetRequestSeqRef: { current: 0 },
initializedHandlesRef: { current: new Set() },
terminalDiagnosticsRef: { current: { resetRoute: () => {} } },
activeHandleRef: { current: null },
activeSessionTabTypeRef: { current: null },
pendingSelectionRef: { current: null },
selectedSessionTabIdRef: { current: null },
pendingBrowserFocusPageIdRef: { current: null },
pendingTerminalActivationAttemptRef: { current: null },
initialSessionAutoCreateRef: { current: null },
bufferedTerminalDraftState: { resetDrafts: () => {}, clearPendingRestorations: () => {} },
clearPendingLiveInputCommit: () => {},
clearDelayedActionTimers: () => {},
showToast,
clearTerminalCache: () => {},
fetchTerminals: async () => true,
ensureSessionTabs: async () => {}
})
)
return null
}
let renderer: ReturnType<typeof create> | undefined
await act(async () => {
renderer = create(createElement(Session))
})
await act(async () => {
await Promise.resolve()
})
act(() => renderer?.unmount())
return { showToast }
}
describe('opening a session wakes its slept agents where the desktop holds them', () => {
it("asks the desktop to wake a server workspace's agents, and says once when nothing could", async () => {
const server = fakeClient(() => ({
activated: true,
sleepingAgentWake: 'unsupported-headless'
}))
const desktop = fakeClient(() => ({ sleepingAgentWake: 'unsupported-headless' }))
const { showToast } = await openSession({
client: server,
desktopClient: desktop,
executionHost: 'runtime:env-1'
})
expect(server.sendRequest.mock.calls.map(([method]) => method)).toEqual(['worktree.activate'])
expect(desktop.sendRequest.mock.calls.map(([method, params]) => [method, params])).toEqual([
['mobileRelay.hosts.wakeSleepingAgents', { hostId: 'runtime:env-1', worktreeId: WORKTREE_ID }]
])
expect(showToast.mock.calls).toEqual([['Open Orca on the host to wake sleeping agents.', 3000]])
})
it("leaves the desktop's own workspace to its activation", async () => {
const desktop = fakeClient(() => ({ activated: true, sleepingAgentWake: 'requested' }))
const { showToast } = await openSession({ client: desktop, desktopClient: desktop })
expect(desktop.sendRequest.mock.calls.map(([method]) => method)).toEqual(['worktree.activate'])
expect(showToast).not.toHaveBeenCalled()
})
})
@@ -1,7 +1,6 @@
import { useEffect } from 'react'
import { worktreeActivate } from '../host-screen/host-screen-operations'
import { headlessActivationNeedsHostRenderer } from '../worktree/worktree-activation-result'
import { relayHostSleepingAgentWake } from '../worktree/server-workspace-operations'
import { createInitialSessionAutoCreateState } from './use-initial-session-terminal-autocreate'
import type { MobileSessionKeyboardStateModel } from './use-mobile-session-keyboard-state'
import type { RpcResponse } from '../transport/types'
@@ -14,8 +13,6 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
isFloatingWorkspaceRoute,
connState,
client,
desktopClient,
executionHost,
setTerminals,
terminalsRef,
setSessionTabs,
@@ -118,21 +115,17 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
timers.push(setTimeout(fn, ms))
}
void (async () => {
// Once per open: a server workspace hears from its server and from the desktop.
let headlessWakeShown = false
const reportHeadlessWake = (result: unknown): void => {
if (!disposed && !headlessWakeShown && headlessActivationNeedsHostRenderer(result)) {
headlessWakeShown = true
showToast('Open Orca on the host to wake sleeping agents.', 3000)
}
}
// Why the reply rather than a verdict: both activations report through here, and only one of
// them can fail to get a reply at all. Interpreting inside keeps the absent case spelled
// `null` instead of a hand-built refusal that has to stay in step with the operation.
const reportActivationOutcome = (response: RpcResponse | null): void => {
const activation = response === null ? null : worktreeActivate.interpret(response)
if (activation?.accepted === true) {
reportHeadlessWake(activation.value)
if (
!disposed &&
activation?.accepted === true &&
headlessActivationNeedsHostRenderer(activation.value)
) {
showToast('Open Orca on the host to wake sleeping agents.', 3000)
}
}
if (client && created !== '1' && !isFloatingWorkspaceRoute) {
@@ -145,18 +138,6 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
})
.then(reportActivationOutcome)
.catch(() => null)
// Why: the desktop's renderer holds a server workspace's slept agents, as it does its own.
if (executionHost && desktopClient) {
void relayHostSleepingAgentWake
.request(desktopClient, { hostId: executionHost, worktreeId })
.then((response) => {
const wake = relayHostSleepingAgentWake.interpret(response)
if (wake.accepted) {
reportHeadlessWake(wake.value)
}
})
.catch(() => null)
}
}
if (disposed) {
return
@@ -204,8 +185,6 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
client,
connState,
created,
desktopClient,
executionHost,
fetchTerminals,
ensureSessionTabs,
isFloatingWorkspaceRoute,
+3 -12
View File
@@ -5,18 +5,9 @@ import type { RpcClient } from './rpc-client'
// Provided by `WorkspaceRoute` for a server's workspace (null until reachable); absent otherwise.
export const WorkspaceServerClientContext = createContext<RpcClient | null | undefined>(undefined)
/**
* `useHostClient` for a workspace screen: its calls run where the workspace does. `desktopClient`
* is the paired desktop's own, for what it alone holds about the workspace.
*/
export function useWorkspaceClient(
hostId: string | undefined
): ReturnType<typeof useHostClient> & { desktopClient: RpcClient | null } {
/** `useHostClient` for a workspace screen: its calls run where the workspace does. */
export function useWorkspaceClient(hostId: string | undefined): ReturnType<typeof useHostClient> {
const host = useHostClient(hostId)
const serverClient = useContext(WorkspaceServerClientContext)
return {
...host,
client: serverClient === undefined ? host.client : serverClient,
desktopClient: host.client
}
return serverClient === undefined ? host : { ...host, client: serverClient }
}
@@ -2,8 +2,6 @@ import { z } from 'zod'
import type { ExecutionHostHealth } from '../../../src/shared/execution-host-health'
import { parseExecutionHostId } from '../../../src/shared/execution-host'
import {
MOBILE_RELAY_HOST_SLEEP_WORKTREE_METHOD,
MOBILE_RELAY_HOST_WAKE_SLEEPING_AGENTS_METHOD,
MOBILE_RELAY_HOST_WORKTREES_METHOD,
MOBILE_RELAY_HOSTS_LIST_METHOD,
type MobileRelayHost,
@@ -73,28 +71,3 @@ export const relayHostWorktreesRead = bindDeferredRpcOperation(
)
})
)
/** Sleeping a server's workspace on the desktop, whose renderer runs the sleep. Fire and forget. */
export const relayHostWorktreeSleep = bindDeferredRpcOperation(
defineRpcOperation({
name: 'mobileRelay.host-sleep-worktree-or-skip',
method: MOBILE_RELAY_HOST_SLEEP_WORKTREE_METHOD,
acceptance: 'success-result-or-skip',
barrier: 'after-caller-barrier',
read: rpcResultVariant('relay-host-worktree-slept', z.unknown())
})
)
/**
* Waking a server workspace's slept agents on the desktop that holds them. Read like
* `worktree.activate`'s reply: only a headless verdict is looked at, by a total guard.
*/
export const relayHostSleepingAgentWake = bindDeferredRpcOperation(
defineRpcOperation({
name: 'mobileRelay.host-wake-sleeping-agents-or-skip',
method: MOBILE_RELAY_HOST_WAKE_SLEEPING_AGENTS_METHOD,
acceptance: 'success-result-or-skip',
barrier: 'after-caller-barrier',
read: rpcResultVariant('relay-host-sleeping-agents-woken', z.unknown())
})
)
@@ -1,8 +1,4 @@
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import {
composeWorktreeHostIdentity,
getExecutionHostIdFromWorktreeHostIdentity
} from '../../../src/shared/worktree/host-qualified-identity'
import { composeWorktreeHostIdentity } from '../../../src/shared/worktree/host-qualified-identity'
import type { Worktree } from './workspace-list-types'
type WorktreeRowIdentitySource = Pick<Worktree, 'worktreeId' | 'hostId'>
@@ -35,14 +31,9 @@ export function clearConfirmedActiveWorktreeIdentity(
: pending
}
/**
* Keeps an optimistic sleep until a list that speaks for the row's host shows it inactive. A list
* judges only its own hosts' rows: the desktop's list never carries a server's, and vice versa.
*/
export function retainLiveSleptWorktreeIdentities(
previous: Set<string>,
confirmed: readonly Worktree[],
speaksFor: (host: ExecutionHostId | undefined) => boolean = () => true
confirmed: readonly Worktree[]
): Set<string> {
if (previous.size === 0) {
return previous
@@ -58,10 +49,7 @@ export function retainLiveSleptWorktreeIdentities(
const still = new Set<string>()
for (const id of previous) {
const wt = confirmedByIdentity.get(id)
if (
!speaksFor(getExecutionHostIdFromWorktreeHostIdentity(id)) ||
(wt && wt.liveTerminalCount > 0)
) {
if (wt && wt.liveTerminalCount > 0) {
still.add(id)
}
}
@@ -104,35 +104,33 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
await this.refreshMobileSessionPtyRecords()
this.notifyMobileSessionTabsChanged(worktree.id)
// Why: a phone open must also wake the worktree's slept agents (experimental
// agent sleep) — mobile-scoped so web/desktop are unaffected.
// agent sleep). Only the host renderer holds the sleeping records + wake
// authority, so fire-and-forget ask it — mobile-scoped so web/desktop are
// unaffected. Headless serve has no renderer to wake anything, so report
// that explicitly instead of letting mobile assume the agents resumed.
if (opts.clientKind === 'mobile') {
sleepingAgentWake = this.requestSleepingAgentWake(worktree.id, getRepoExecutionHostId(repo))
if (this.getAvailableAuthoritativeWindow()) {
this.notifier?.resumeSleepingAgents?.(worktree.id)
sleepingAgentWake = 'requested'
} else if (
// Why: sleeping records are partitioned by execution host; reading
// only the local partition would miss slept agents on SSH-host
// worktrees and skip the headless warning for them.
Object.values(
this.store?.getWorkspaceSession?.(getRepoExecutionHostId(repo))
.sleepingAgentSessionsByPaneKey ?? {}
).some((record) => record.worktreeId === worktree.id)
) {
// Why: headless is only degraded when this worktree actually has a
// persisted resume record. Ordinary mobile activation must not show
// an unsupported warning merely because no desktop window is open.
sleepingAgentWake = 'unsupported-headless'
}
}
}
return { repoId: repo.id, worktreeId: worktree.id, activated: true, sleepingAgentWake }
}
/**
* Wakes a worktree's slept agents for a phone. Only the host renderer holds the sleeping records
* and wake authority, so this asks it; with no renderer (headless serve, or the window closed)
* a worktree that has a persisted record reports that nothing could wake it.
*/
requestSleepingAgentWake(
worktreeId: string,
executionHostId: string
): 'requested' | 'unsupported-headless' | 'not-applicable' {
if (this.getAvailableAuthoritativeWindow()) {
this.notifier?.resumeSleepingAgents?.(worktreeId)
return 'requested'
}
// Why the host's partition: sleeping records are partitioned by execution host.
const records =
this.store?.getWorkspaceSession?.(executionHostId).sleepingAgentSessionsByPaneKey ?? {}
return Object.values(records).some((record) => record.worktreeId === worktreeId)
? 'unsupported-headless'
: 'not-applicable'
}
protected async buildStartupForDraft(
repo: Repo,
draft: string,
@@ -176,12 +176,7 @@ export class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWithRestoreS
// Why: sleep is renderer-initiated on desktop (it tears down tab state
// before killing PTYs). The notifier tells the renderer to run its own
// sleep flow so all cleanup happens in the correct order.
return this.requestWorktreeSleep(worktree.id)
}
/** Asks the renderer to sleep a worktree it shows, from any host, through its own sleep flow. */
requestWorktreeSleep(worktreeId: string): { worktreeId: string } {
this.notifier?.sleepWorktree(worktreeId)
return { worktreeId }
this.notifier?.sleepWorktree(worktree.id)
return { worktreeId: worktree.id }
}
}
@@ -1,134 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import {
OrcaRuntimeService,
electronMocks,
getDefaultWorkspaceSession
} from '../orca-runtime-test-mocks.spec'
import { TEST_WINDOW_ID, store } from '../orca-runtime-test-fixtures.spec'
import { RpcDispatcher } from '../rpc/dispatcher'
import { MOBILE_RELAY_HOSTS_METHODS } from '../rpc/methods/mobile-relay-hosts'
const SERVER = 'runtime:env-1'
const SERVER_WORKTREE = 'server-repo::/srv/orca'
/** A desktop whose renderer may or may not be open, holding slept agents in `sleptOnHost`'s partition. */
function desktop(options: { windowOpen: boolean; sleptOnHost?: string }) {
const sleepWorktree = vi.fn()
const resumeSleepingAgents = vi.fn()
const getWorkspaceSession = (hostId: string) => ({
...getDefaultWorkspaceSession(),
sleepingAgentSessionsByPaneKey:
hostId === options.sleptOnHost
? {
'tab-1:leaf-1': {
paneKey: 'tab-1:leaf-1',
tabId: 'tab-1',
worktreeId: SERVER_WORKTREE,
agent: 'codex',
providerSession: { key: 'session_id', id: 'session-1' },
prompt: 'test',
state: 'done',
capturedAt: 1,
updatedAt: 1,
origin: 'worktree-sleep'
}
}
: {}
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture store with one partitioned session reader.
const runtime = new OrcaRuntimeService({ ...store, getWorkspaceSession } as never)
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession: vi.fn(),
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree,
resumeSleepingAgents,
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
electronMocks.BrowserWindow.fromId.mockReturnValue(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only isDestroyed.
(options.windowOpen ? { isDestroyed: () => false } : null) as never
)
runtime.attachWindow(TEST_WINDOW_ID)
runtime.markGraphReady(TEST_WINDOW_ID)
const dispatcher = new RpcDispatcher({ runtime, methods: MOBILE_RELAY_HOSTS_METHODS })
// The desktop's configured servers, as its settings list them.
const mobileRelayHosts = {
list: () => ({
hosts: [{ hostId: SERVER, label: 'VM', health: 'available', relay: 'ready' } as const]
}),
worktrees: async () => ({ worktrees: null })
}
// The phone's socket path, which is what hands the handlers the desktop's servers.
const call = async (method: string, params: unknown): Promise<unknown> => {
const replies: string[] = []
await dispatcher.dispatchStreaming(
{ id: 'request-1', authToken: 'token', method, params },
(reply) => replies.push(reply),
{ mobileRelayHosts }
)
return JSON.parse(replies[0] ?? 'null')
}
return { call, sleepWorktree, resumeSleepingAgents }
}
describe('a phone sleeping and waking a server workspace through its desktop', () => {
it("runs the desktop renderer's sleep for the server's workspace", async () => {
const { call, sleepWorktree } = desktop({ windowOpen: true })
const response = await call('mobileRelay.hosts.sleepWorktree', {
hostId: SERVER,
worktreeId: SERVER_WORKTREE
})
expect(response).toMatchObject({ ok: true, result: { worktreeId: SERVER_WORKTREE } })
expect(sleepWorktree).toHaveBeenCalledWith(SERVER_WORKTREE)
})
it('asks the desktop renderer, which holds the slept agents, to wake them', async () => {
const { call, resumeSleepingAgents } = desktop({ windowOpen: true, sleptOnHost: SERVER })
const response = await call('mobileRelay.hosts.wakeSleepingAgents', {
hostId: SERVER,
worktreeId: SERVER_WORKTREE
})
expect(response).toMatchObject({ ok: true, result: { sleepingAgentWake: 'requested' } })
expect(resumeSleepingAgents).toHaveBeenCalledWith(SERVER_WORKTREE)
})
it("says nothing could wake them with the window closed, reading that server's records", async () => {
const onServer = desktop({ windowOpen: false, sleptOnHost: SERVER })
const elsewhere = desktop({ windowOpen: false, sleptOnHost: 'local' })
const params = { hostId: SERVER, worktreeId: SERVER_WORKTREE }
expect(await onServer.call('mobileRelay.hosts.wakeSleepingAgents', params)).toMatchObject({
result: { sleepingAgentWake: 'unsupported-headless' }
})
expect(await elsewhere.call('mobileRelay.hosts.wakeSleepingAgents', params)).toMatchObject({
result: { sleepingAgentWake: 'not-applicable' }
})
expect(onServer.resumeSleepingAgents).not.toHaveBeenCalled()
})
it("refuses a target that is not one of the desktop's configured servers", async () => {
const { call, sleepWorktree, resumeSleepingAgents } = desktop({ windowOpen: true })
for (const hostId of ['local', 'runtime:removed']) {
const params = { hostId, worktreeId: SERVER_WORKTREE }
expect(await call('mobileRelay.hosts.sleepWorktree', params)).toMatchObject({ ok: false })
expect(await call('mobileRelay.hosts.wakeSleepingAgents', params)).toMatchObject({
ok: false
})
}
expect(sleepWorktree).not.toHaveBeenCalled()
expect(resumeSleepingAgents).not.toHaveBeenCalled()
})
})
-1
View File
@@ -123,4 +123,3 @@ await import('./orca-runtime-tests/worktree-removal-failed-retry.spec')
await import('./orca-runtime-tests/worktree-removal-execution-host.spec')
await import('./orca-runtime-tests/targeting-and-resilience.spec')
await import('./orca-runtime-tests/worktree-scan-cache-ttl.spec')
await import('./orca-runtime-tests/mobile-relay-host-sleep-wake.spec')
@@ -1,20 +1,10 @@
import { defineMethod, type RpcContext } from '../core'
import { defineMethod } from '../core'
import {
MOBILE_RELAY_HOST_SLEEP_WORKTREE_METHOD,
MOBILE_RELAY_HOST_WAKE_SLEEPING_AGENTS_METHOD,
MOBILE_RELAY_HOST_WORKTREES_METHOD,
MOBILE_RELAY_HOSTS_LIST_METHOD,
MobileRelayHostWorktreeParamsSchema,
MobileRelayHostWorktreesParamsSchema
} from '../../../../shared/mobile-relay-hosts-contract'
/** Refuses a server the desktop has not configured; read from its settings, not a fetched list. */
function assertConfiguredServer(hosts: RpcContext['mobileRelayHosts'], hostId: string): void {
if (!hosts?.list().hosts.some((host) => host.hostId === hostId)) {
throw new Error('selector_not_found')
}
}
export const MOBILE_RELAY_HOSTS_METHODS = [
defineMethod({
name: MOBILE_RELAY_HOSTS_LIST_METHOD,
@@ -27,23 +17,5 @@ export const MOBILE_RELAY_HOSTS_METHODS = [
params: MobileRelayHostWorktreesParamsSchema,
handler: async (params, ctx) =>
(await ctx.mobileRelayHosts?.worktrees(params.hostId)) ?? { worktrees: null }
}),
defineMethod({
name: MOBILE_RELAY_HOST_SLEEP_WORKTREE_METHOD,
params: MobileRelayHostWorktreeParamsSchema,
handler: (params, { runtime, mobileRelayHosts }) => {
assertConfiguredServer(mobileRelayHosts, params.hostId)
return runtime.requestWorktreeSleep(params.worktreeId)
}
}),
defineMethod({
name: MOBILE_RELAY_HOST_WAKE_SLEEPING_AGENTS_METHOD,
params: MobileRelayHostWorktreeParamsSchema,
handler: (params, { runtime, mobileRelayHosts }) => {
assertConfiguredServer(mobileRelayHosts, params.hostId)
return {
sleepingAgentWake: runtime.requestSleepingAgentWake(params.worktreeId, params.hostId)
}
}
})
]
@@ -179,8 +179,6 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'markdown.readTab',
'markdown.saveTab',
'mobileRelay.hosts.list',
'mobileRelay.hosts.sleepWorktree',
'mobileRelay.hosts.wakeSleepingAgents',
'mobileRelay.hosts.worktrees',
'mobileWeb.bundle.chunk',
'mobileWeb.bundle.manifest',
@@ -12,11 +12,8 @@ export const PAIRED_DESKTOP_METHODS = [
'notifications.unsubscribe',
'pairing.getEndpoints',
'pairing.provisionRelay',
// The configured servers the desktop shows, their workspace lists as it last fetched them, and
// the sleep and slept-agent wake its renderer owns for them.
// The configured servers the desktop shows, and their workspace lists as it last fetched them.
'mobileRelay.hosts.list',
'mobileRelay.hosts.sleepWorktree',
'mobileRelay.hosts.wakeSleepingAgents',
'mobileRelay.hosts.worktrees',
'speech.dictation.cancel',
'speech.dictation.chunk',
-11
View File
@@ -4,9 +4,6 @@ import type { ExecutionHostHealth } from './execution-host-health'
// Why: both are answered by the paired desktop itself; the phone lists servers the desktop shows.
export const MOBILE_RELAY_HOSTS_LIST_METHOD = 'mobileRelay.hosts.list'
export const MOBILE_RELAY_HOST_WORKTREES_METHOD = 'mobileRelay.hosts.worktrees'
// Why the desktop's: its renderer alone sleeps a server's workspace and holds its slept agents.
export const MOBILE_RELAY_HOST_SLEEP_WORKTREE_METHOD = 'mobileRelay.hosts.sleepWorktree'
export const MOBILE_RELAY_HOST_WAKE_SLEEPING_AGENTS_METHOD = 'mobileRelay.hosts.wakeSleepingAgents'
/** Whether the desktop can relay the phone to this server now; `update-needed` is the server's build. */
export type MobileRelayHostRelay = 'ready' | 'update-needed' | 'unavailable'
@@ -24,14 +21,6 @@ export const MobileRelayHostWorktreesParamsSchema = z
.object({ hostId: z.string().min(1).max(1024) })
.strict()
/** A server's workspace, by the server's host id and its own worktree id. */
export const MobileRelayHostWorktreeParamsSchema = z
.object({
hostId: z.string().startsWith('runtime:').max(1024),
worktreeId: z.string().min(1).max(4096)
})
.strict()
/** One of a server's own `worktree.ps` rows (RuntimeWorktreePsSummary), carrying the server's host id. */
export type MobileRelayServerWorktreeRow = Record<string, unknown> & { hostId: `runtime:${string}` }
+1 -6
View File
@@ -23,10 +23,7 @@ import {
PairingGetEndpointsParamsSchema,
PairingProvisionRelayParamsSchema
} from '../mobile-relay-credential-contract'
import {
MobileRelayHostWorktreeParamsSchema,
MobileRelayHostWorktreesParamsSchema
} from '../mobile-relay-hosts-contract'
import { MobileRelayHostWorktreesParamsSchema } from '../mobile-relay-hosts-contract'
import { MobileWebBundleChunkParamsSchema } from '../mobile-web-bundle/bundle-rpc-contract'
import { OrcadMigrationSnapshotChunkRequestSchema } from '../orcad-migration-scrollback'
import { OrcadTerminalCensusParamsSchema } from '../orcad-terminal-census'
@@ -1035,8 +1032,6 @@ export const RPC_PARAMS_BY_METHOD = {
'markdown.readTab': ActivateTab,
'markdown.saveTab': SaveMarkdownTab,
'mobileRelay.hosts.list': null,
'mobileRelay.hosts.sleepWorktree': MobileRelayHostWorktreeParamsSchema,
'mobileRelay.hosts.wakeSleepingAgents': MobileRelayHostWorktreeParamsSchema,
'mobileRelay.hosts.worktrees': MobileRelayHostWorktreesParamsSchema,
'mobileWeb.bundle.chunk': MobileWebBundleChunkParamsSchema,
'mobileWeb.bundle.manifest': null,
+1 -35
View File
@@ -24,9 +24,6 @@ const CreatedTabSchema = z.object({ tab: z.looseObject({ id: z.string(), termina
const ReposSchema = z.object({
repos: z.array(z.looseObject({ id: z.string(), path: z.string() }))
})
const ServerTerminalsSchema = z.looseObject({
terminals: z.array(z.looseObject({ handle: z.string() }))
})
const ServerTabsSchema = z.looseObject({ tabs: z.array(z.looseObject({ id: z.string() })) })
const SubscribedSchema = z.looseObject({ type: z.literal('subscribed'), streamId: z.number() })
const HostsSchema = z.object({
@@ -324,7 +321,7 @@ function desktopTerminalHandles(desktop: { page: Page }, worktreeId: string): Pr
)
}
test('phone tab, workspace and sleep actions on a server show on the server and the desktop', async (// oxlint-disable-next-line no-empty-pattern -- this test owns its topology launch.
test('phone tab and workspace actions on a server show on the server and the desktop', async (// oxlint-disable-next-line no-empty-pattern -- this test owns its topology launch.
{}, testInfo) => {
test.setTimeout(180_000)
const serverFolder = testInfo.outputPath('server-folder')
@@ -387,10 +384,6 @@ test('phone tab, workspace and sleep actions on a server show on the server and
const serverTabs = async () =>
ServerTabsSchema.parse((await host.client.call('session.tabs.list', { worktree })).result)
.tabs
const serverTerminals = async () =>
ServerTerminalsSchema.parse(
(await host.client.call('terminal.list', { worktree })).result
).terminals.map((terminal) => terminal.handle)
const create = (id: string) =>
call(
socket,
@@ -427,33 +420,6 @@ test('phone tab, workspace and sleep actions on a server show on the server and
await expect
.poll(() => desktopTerminalHandles(desktop, worktreeId), { timeout: 30_000 })
.toEqual([kept.terminal])
// Waking and sleeping are the desktop renderer's. The wake check proves delivery to it, not an
// agent resuming (none is slept here).
await desktop.page.evaluate(() => {
const woken: string[] = []
Object.assign(window, { __wokenWorktrees: woken })
window.api.ui.onResumeSleepingAgents(({ worktreeId }) => woken.push(worktreeId))
})
const target = { hostId: serverHostId, worktreeId }
const wake = await call(
socket,
{ id: 'wake', method: 'mobileRelay.hosts.wakeSleepingAgents', params: target },
z.looseObject({ sleepingAgentWake: z.string() })
)
expect(wake.sleepingAgentWake).toBe('requested')
await expect
.poll(() => desktop.page.evaluate(() => Reflect.get(window, '__wokenWorktrees')))
.toEqual([worktreeId])
expect(await serverTerminals()).toEqual([kept.terminal])
await call(
socket,
{ id: 'sleep', method: 'mobileRelay.hosts.sleepWorktree', params: target },
z.unknown()
)
// The desktop's sleep releases the workspace's processes on the server and keeps its tabs.
await expect.poll(serverTerminals, { timeout: 30_000 }).toEqual([])
expect(await desktopTerminalHandles(desktop, worktreeId)).toEqual([kept.terminal])
} finally {
await dispose()
}