mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user