diff --git a/src/main/ipc/runtime-environment-mobile-relay-hosts.ts b/src/main/ipc/runtime-environment-mobile-relay-hosts.ts index 50b39ad2dfd..fa875217a94 100644 --- a/src/main/ipc/runtime-environment-mobile-relay-hosts.ts +++ b/src/main/ipc/runtime-environment-mobile-relay-hosts.ts @@ -10,12 +10,23 @@ import { runtimeEnvironmentStatusFromSnapshot, type RuntimeEnvironmentStatus } from '../../shared/runtime-host-status' -import type { MobileDesktopRelayHosts } from '../runtime/mobile-desktop-relay/mobile-desktop-relay-hosts' +import type { + MobileDesktopRelayHosts, + MobileDesktopRelayHostWatch +} from '../runtime/mobile-desktop-relay/mobile-desktop-relay-hosts' import { resolveManagedRuntimeEnvironment } from './runtime-environment-managed-tunnel' import type { SshConnectionState } from '../../shared/ssh-types' import { getRegisteredSshState, listRegisteredSshTargets } from '../ssh/ssh-target-registry' import { getRuntimeEnvironmentStatusSnapshots } from './runtime-environment-request-connections' -import { callRuntimeEnvironment } from './runtime-environment-transport-routing' +import { + callRuntimeEnvironment, + subscribeRuntimeEnvironment +} from './runtime-environment-transport-routing' +import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' +import { isRuntimeSubscriptionReplayResponse } from '../../shared/runtime-subscription-replay' +import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' +import { onRuntimeEnvironmentStatusPublished } from './runtime-environment-status-owner' +import { getRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' const retirementListeners = new Set<(environmentId: string) => void>() @@ -110,8 +121,85 @@ export function createRuntimeEnvironmentMobileRelayHosts(): MobileDesktopRelayHo onEnvironmentRetired: (listener) => { retirementListeners.add(listener) return () => retirementListeners.delete(listener) + }, + onStatusChanged: onRuntimeEnvironmentStatusPublished, + watchChanges: (host, expected, callbacks) => + watchRuntimeEnvironmentChanges(userDataPath, host.environmentId, expected, callbacks) + } +} + +/** Main's own client-event stream to one server, bound to the transport and pairing it opened on. */ +function watchRuntimeEnvironmentChanges( + userDataPath: string, + environmentId: string, + expected: { pairingRevision: number; runtimeId: string | null }, + callbacks: { onChange: () => void; onClose: () => void } +): MobileDesktopRelayHostWatch { + const controller = new AbortController() + let subscription: RemoteRuntimeSubscription | null = null + const generation = getRuntimeEnvironmentTransportGeneration(environmentId) + const current = (): boolean => + !controller.signal.aborted && + getRuntimeEnvironmentTransportGeneration(environmentId) === generation + const close = (): void => { + controller.abort() + subscription?.close() + } + const end = (): void => { + if (!controller.signal.aborted) { + close() + callbacks.onClose() } } + const environment = listRelayableEnvironments(userDataPath).find( + (entry) => entry.id === environmentId + ) + if ( + !environment || + isRuntimeEnvironmentManuallyDisconnected(environmentId) || + pairingRevision(environment) !== expected.pairingRevision || + environment.runtimeId !== expected.runtimeId + ) { + queueMicrotask(end) + return { close, current } + } + subscribeRuntimeEnvironment( + userDataPath, + environmentId, + 'runtime.clientEvents.subscribe', + undefined, + 15_000, + { + onEvent: (payload) => { + if (payload.type === 'response' && payload.response.ok && current()) { + const { response } = payload + if (isRuntimeSubscriptionReplayResponse(response) || isWorkspaceChange(response.result)) { + callbacks.onChange() + } + } + }, + onClose: end + }, + current, + controller.signal + ).then((opened) => { + subscription = opened + if (controller.signal.aborted) { + opened.close() + } + }, end) + return { close, current } +} + +function isWorkspaceChange(message: unknown): boolean { + if (typeof message !== 'object' || message === null || !('type' in message)) { + return false + } + return ( + message.type === 'ready' || + message.type === 'worktreesChanged' || + message.type === 'reposChanged' + ) } function pairingRevision(environment: KnownRuntimeEnvironment): number { diff --git a/src/main/ipc/runtime-environment-status-owner.ts b/src/main/ipc/runtime-environment-status-owner.ts index c399a569528..7e0e6f1ce2a 100644 --- a/src/main/ipc/runtime-environment-status-owner.ts +++ b/src/main/ipc/runtime-environment-status-owner.ts @@ -23,6 +23,14 @@ import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment- import { runtimeEnvironmentChangedFailure } from './runtime-environment-revision-guard' import { resolveManagedRuntimeEnvironment } from './runtime-environment-managed-tunnel' +const statusListeners = new Set<() => void>() + +/** Main's own readers of server status, alongside the windows it is sent to. */ +export function onRuntimeEnvironmentStatusPublished(listener: () => void): () => void { + statusListeners.add(listener) + return () => statusListeners.delete(listener) +} + export function createRuntimeEnvironmentStatusOwner( userDataPath: string, environment: KnownRuntimeEnvironment, @@ -98,6 +106,9 @@ export function createRuntimeEnvironmentStatusOwner( /* A renderer can close during publication. */ } } + for (const listener of statusListeners) { + listener() + } } }) } diff --git a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts index 6f2a8b202a9..e87ed95fb59 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts @@ -57,4 +57,22 @@ export type MobileDesktopRelayHosts = { hostsBrowserPage: (environmentId: string, browserPageId: string) => boolean /** Fires when a server is removed, re-paired or disconnected; returns an unsubscribe. */ onEnvironmentRetired: (listener: (environmentId: string) => void) => () => void + /** Fires whenever the desktop republishes a server's status; returns an unsubscribe. */ + onStatusChanged: (listener: () => void) => () => void + /** + * The server's own workspace change events as the desktop itself, needing no window. `onChange` + * fires once the stream is ready, on `worktreesChanged`/`reposChanged`, and on a replay after a + * reconnect, which may have missed events; `onClose` when the stream ends. + */ + watchChanges: ( + host: Pick, + expected: NonNullable, + callbacks: { onChange: () => void; onClose: () => void } + ) => MobileDesktopRelayHostWatch +} + +export type MobileDesktopRelayHostWatch = { + close: () => void + /** False once closed, ended, or its transport was replaced, which can end it without `onClose`. */ + current: () => boolean } diff --git a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts index 83f970f50d5..9d812269ac1 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts @@ -189,7 +189,9 @@ describe('mobile desktop relay: phone -> desktop -> server', () => { onEnvironmentRetired: (listener) => { retirementListeners.add(listener) return () => retirementListeners.delete(listener) - } + }, + onStatusChanged: () => () => {}, + watchChanges: () => ({ close: () => {}, current: () => true }) } desktop.server.setMobileDesktopRelayHosts(hosts) const metadata = readRuntimeMetadata(host.userDataPath)! diff --git a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.test.ts index e456cd787d6..deeb697cda9 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.test.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.test.ts @@ -79,7 +79,9 @@ function relayWithPhone( return syncResponse() }, hostsBrowserPage: () => false, - onEnvironmentRetired: () => () => {} + onEnvironmentRetired: () => () => {}, + onStatusChanged: () => () => {}, + watchChanges: () => ({ close: () => {}, current: () => true }) } const relay = new MobileDesktopRelay({ hosts, diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.integration.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.integration.test.ts index f9e5b734530..6b7f0506805 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.integration.test.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.integration.test.ts @@ -143,7 +143,9 @@ describe('mobile relay hosts: phone -> desktop lists the servers it shows', () = 5_000 ), hostsBrowserPage: () => false, - onEnvironmentRetired: () => () => {} + onEnvironmentRetired: () => () => {}, + onStatusChanged: () => () => {}, + watchChanges: () => ({ close: () => {}, current: () => true }) } desktop.setMobileDesktopRelayHosts(hosts) const session = await authenticateMobileWsSession(pairingUrl(desktop, 'mobile')) diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts index 6bfe93ef2d2..b525e148a32 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts @@ -14,6 +14,7 @@ import type { BrowserWorkspace } from '../../../shared/browser-workspace-types' import type { Tab } from '../../../shared/tab-types' import type { MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts' import { MobileRelayHostCatalog } from './mobile-relay-host-catalog' +import { PairedServerRowsProducer } from '../sidebar-inputs/paired-server-rows-producer' function answer(capable: boolean): RuntimeStatus { return { @@ -102,7 +103,9 @@ function fakeHosts( onEnvironmentRetired: (listener) => { state.retire = listener return () => {} - } + }, + onStatusChanged: () => () => {}, + watchChanges: () => ({ close: () => {}, current: () => true }) } return { hosts, call, state } } @@ -114,9 +117,15 @@ function catalog( sessions?: Record> } = {} ) { + const serverRows = new PairedServerRowsProducer({ + hasDemand: () => false, + hostLabelOverrides: () => new Map() + }) + serverRows.attach(hosts) return { catalog: new MobileRelayHostCatalog({ hosts, + serverRows, hostLabelOverrides: () => labels, getWorkspaceSession: (hostId) => { const session = desktop.sessions?.[hostId] @@ -302,7 +311,7 @@ describe('mobile relay host catalog', () => { await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({ worktrees: null }) }) - it('has no rows when re-paired mid-fetch, and the next poll fetches the new server afresh', async () => { + it('never serves rows from before a re-pair mid-fetch, and the next poll fetches the new server afresh', async () => { const { hosts, call, state } = fakeHosts([ { id: 'env', name: 'Box', snapshot: snapshot('env', 'live') } ]) @@ -322,7 +331,13 @@ describe('mobile relay host catalog', () => { }) await expect(next).resolves.toMatchObject({ worktrees: [{ worktreeId: 'new-server-row' }] }) answerOldFetch(psReply([{ worktreeId: 'old-server-row', hostId: 'local' }])) - await expect(rows).resolves.toEqual({ worktrees: null }) + // The old server's late answer is dropped; the earlier request reads the new server's rows. + await expect(rows).resolves.toEqual({ + worktrees: [{ worktreeId: 'new-server-row', hostId: 'runtime:env' }] + }) + await expect(hostCatalog.worktrees('runtime:env')).resolves.toMatchObject({ + worktrees: [{ worktreeId: 'new-server-row' }] + }) }) it("forgets a removed server's rows, even ones its last fetch delivers after removal", async () => { diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts index 2c8968b14dd..cff3307a8f0 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts @@ -1,21 +1,13 @@ -import { z } from 'zod' -import { DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY } from '../../../shared/delegated-mobile-device-contract' import { parseExecutionHostId, toRuntimeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' -import { buildExecutionHostRegistry } from '../../../shared/execution-host-registry' -import { pickerExecutionHosts } from '../../../shared/managed-orcad-execution-host' import type { - MobileRelayHost, - MobileRelayHostRelay, MobileRelayHostsListResult, MobileRelayHostWorktreesResult, MobileRelayServerWorktreeRow } from '../../../shared/mobile-relay-hosts-contract' -import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' -import type { RuntimeStatus } from '../../../shared/runtime-types' import { getPairedDeviceIdsByEnvironment, isWorkspaceOwnedBySomeoneElse @@ -23,27 +15,12 @@ import { import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { getWorkspaceSessionTabActivityIds } from '../../../shared/workspace-session-tab-activity' import { runtimeWorktreeIdentityKey } from '../runtime-worktree-path-identity' -import type { - MobileDesktopRelayHostListing, - MobileDesktopRelayHosts -} from './mobile-desktop-relay-hosts' - -// Why the phone's own full-catalog limit: the server's default page is 200 and nothing pages on. -const SERVER_WORKTREE_LIMIT = 10_000 -// Why well under the phone's 30s request timeout: a hung server must answer with its last rows. -const SERVER_FETCH_TIMEOUT_MS = 5_000 - -const WorktreePsReplySchema = z.object({ worktrees: z.array(z.looseObject({})) }) - -type CachedServerWorktrees = { - fence: string - worktrees: MobileRelayServerWorktreeRow[] -} - -type DescribedHost = MobileRelayHost & { - environmentId: string - identity: Pick -} +import { + SERVER_FETCH_TIMEOUT_MS, + type PairedServerRowsProducer +} from '../sidebar-inputs/paired-server-rows-producer' +import type { MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts' +import { describeRelayHosts, relayHostFence } from './mobile-relay-host-descriptions' /** * The configured servers the desktop shows, for the phone: health from the desktop's own status @@ -52,13 +29,11 @@ type DescribedHost = MobileRelayHost & { * never read here. */ export class MobileRelayHostCatalog { - private readonly cached = new Map() - // Keyed by server identity, so a poll after a re-pair never joins the old server's fetch. - private readonly refreshing = new Map>() - constructor( private readonly options: { hosts: MobileDesktopRelayHosts + /** The desktop's one live set of server rows; this catalog keeps no copy. */ + serverRows: PairedServerRowsProducer hostLabelOverrides: () => ReadonlyMap /** The desktop's saved session for a host, as its sidebar last wrote it. */ getWorkspaceSession: (hostId: ExecutionHostId) => WorkspaceSessionState | null @@ -67,32 +42,35 @@ export class MobileRelayHostCatalog { list(): MobileRelayHostsListResult { return { - hosts: this.describe().map(({ hostId, label, health, relay }) => ({ - hostId, - label, - health, - relay - })) + hosts: describeRelayHosts(this.options.hosts, this.options.hostLabelOverrides()).map( + ({ hostId, label, health, relay }) => ({ hostId, label, health, relay }) + ) } } async worktrees(hostId: string): Promise { const parsed = parseExecutionHostId(hostId) + // Why sync first: a re-pair since the last status publication must fetch under the new pairing. + this.options.serverRows.sync() const host = parsed?.kind === 'runtime' - ? this.describe().find((entry) => entry.environmentId === parsed.environmentId) + ? describeRelayHosts(this.options.hosts).find( + (entry) => entry.environmentId === parsed.environmentId + ) : undefined if (!host) { return { worktrees: null } } if (host.health === 'available') { - await withinFetchBound(this.refresh(host)) + await withinFetchBound(this.options.serverRows.refresh(host.environmentId)) } - const cached = this.cached.get(host.environmentId) const { environments, statusByEnvironmentId } = this.options.hosts.list() const current = environments.find((environment) => environment.id === host.environmentId) // Why the identity read after the fetch: one answered after a re-pair stored the old server's rows. - if (!cached || !current || cached.fence !== fenceOf(current)) { + const rows = current + ? this.options.serverRows.rows(host.environmentId, relayHostFence(current)) + : null + if (!current || !rows) { return { worktrees: null } } const desktopDeviceIds = getPairedDeviceIdsByEnvironment([current], statusByEnvironmentId) @@ -105,93 +83,13 @@ export class MobileRelayHostCatalog { ) // Why: the desktop asks with background-removal support; a phone asking itself never sees these rows. return { - worktrees: cached.worktrees + worktrees: rows .filter((row) => row.removing !== true) .map((row) => stampDesktopSidebarView(row, host.environmentId, desktopDeviceIds, sessionActivity) ) } } - - private describe(): DescribedHost[] { - const { environments, statusByEnvironmentId, sshTargetLabels, sshConnectionStates } = - this.options.hosts.list() - const identities = new Map(environments.map((environment) => [environment.id, environment])) - // Why lazily, not on retirement: a disconnected server keeps its rows; only a removed one loses them. - for (const environmentId of this.cached.keys()) { - if (!identities.has(environmentId)) { - this.cached.delete(environmentId) - } - } - // Why the desktop's picker rows: a managed server folds into its SSH host as the sidebar shows it. - return pickerExecutionHosts( - buildExecutionHostRegistry({ - repos: [], - settings: null, - hostSource: 'configured-only', - sshTargetLabels, - sshConnectionStates, - runtimeEnvironments: environments, - runtimeStatusByEnvironmentId: statusByEnvironmentId, - hostLabelOverrides: this.options.hostLabelOverrides() - }) - ).flatMap((entry) => { - const parsed = parseExecutionHostId(entry.id) - const identity = parsed?.kind === 'runtime' ? identities.get(parsed.environmentId) : undefined - if (parsed?.kind !== 'runtime' || !identity) { - return [] - } - const answer = lastVerifiedRuntimeStatus(statusByEnvironmentId.get(parsed.environmentId)) - return [ - { - hostId: parsed.id, - environmentId: parsed.environmentId, - identity: { pairingRevision: identity.pairingRevision, runtimeId: identity.runtimeId }, - label: entry.label, - health: entry.health, - relay: relayVerdict(entry.health, answer) - } - ] - }) - } - - private refresh(host: DescribedHost): Promise { - const key = `${host.environmentId}\0${fenceOf(host.identity)}` - const inFlight = this.refreshing.get(key) - if (inFlight) { - return inFlight - } - const refresh = this.fetchAsDesktop(host).finally(() => this.refreshing.delete(key)) - this.refreshing.set(key, refresh) - return refresh - } - - private async fetchAsDesktop(host: DescribedHost): Promise { - try { - const response = await this.options.hosts.call( - host, - 'worktree.ps', - { limit: SERVER_WORKTREE_LIMIT, supportsWorktreeVisibilitySourceDefaults: true }, - // Why the transport bound too: it ends the hung call, so the next poll fetches afresh instead of joining it. - { timeoutMs: SERVER_FETCH_TIMEOUT_MS, expected: host.identity } - ) - const reply = response.ok ? WorktreePsReplySchema.safeParse(response.result) : null - if (!reply?.success) { - return - } - this.cached.set(host.environmentId, { - // Why the identity taken before the fetch: the call's own check cannot see a re-pair after dispatch. - fence: fenceOf(host.identity), - worktrees: stampServerWorktreeRows(host.environmentId, reply.data.worktrees) - }) - } catch { - // An unreachable server keeps its last rows. - } - } -} - -function fenceOf(identity: DescribedHost['identity']): string { - return `${identity.pairingRevision}\0${identity.runtimeId}` } /** Settles once the bound passes; the call's own timeout starts only after the desktop's per-server queue. */ @@ -207,20 +105,6 @@ async function withinFetchBound(refresh: Promise): Promise { } } -function relayVerdict( - health: MobileRelayHost['health'], - answer: RuntimeStatus | null -): MobileRelayHostRelay { - if (!answer) { - return 'unavailable' - } - // Why: a build's capabilities do not expire, so an old server stays update-needed while offline. - if (!(answer.capabilities ?? []).includes(DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY)) { - return 'update-needed' - } - return health === 'available' ? 'ready' : 'unavailable' -} - /** * What the desktop's own sidebar knows and the server cannot: which rows are another client's * (marked, not dropped; the phone applies the shared filter), and its browser and chat tabs as activity. @@ -247,15 +131,3 @@ function stampDesktopSidebarView( ...(active ? { hasHostSidebarActivity: true } : {}) } } - -/** - * A server's rows name hosts relative to that server (`local`, its own `ssh:` targets). Like the - * desktop sidebar, the phone shows every one of them under the server, so all become its host id. - */ -function stampServerWorktreeRows( - environmentId: string, - rows: readonly Row[] -): (Row & { hostId: `runtime:${string}` })[] { - const hostId = toRuntimeExecutionHostId(environmentId) - return rows.map((row) => ({ ...row, hostId })) -} diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-descriptions.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-descriptions.ts new file mode 100644 index 00000000000..9730868d24f --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-descriptions.ts @@ -0,0 +1,64 @@ +import { DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY } from '../../../shared/delegated-mobile-device-contract' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { describePairedServerHosts } from '../../../shared/paired-server-hosts' +import type { + MobileRelayHost, + MobileRelayHostRelay +} from '../../../shared/mobile-relay-hosts-contract' +import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import type { + MobileDesktopRelayHostListing, + MobileDesktopRelayHosts +} from './mobile-desktop-relay-hosts' + +export type DescribedRelayHost = MobileRelayHost & { + environmentId: string + identity: Pick +} + +/** The configured servers the desktop shows, with health from its own status (the sidebar's mapping). */ +export function describeRelayHosts( + hosts: Pick, + hostLabelOverrides: ReadonlyMap = new Map() +): DescribedRelayHost[] { + const listing = hosts.list() + const identities = new Map( + listing.environments.map((environment) => [environment.id, environment]) + ) + return describePairedServerHosts({ ...listing, hostLabelOverrides }).flatMap((host) => { + const identity = identities.get(host.environmentId) + if (!identity) { + return [] + } + const answer = lastVerifiedRuntimeStatus(listing.statusByEnvironmentId.get(host.environmentId)) + return [ + { + hostId: host.hostId, + environmentId: host.environmentId, + identity: { pairingRevision: identity.pairingRevision, runtimeId: identity.runtimeId }, + label: host.label, + health: host.health, + relay: relayVerdict(host.health, answer) + } + ] + }) +} + +export function relayHostFence(identity: DescribedRelayHost['identity']): string { + return `${identity.pairingRevision}\0${identity.runtimeId}` +} + +function relayVerdict( + health: MobileRelayHost['health'], + answer: RuntimeStatus | null +): MobileRelayHostRelay { + if (!answer) { + return 'unavailable' + } + // Why: a build's capabilities do not expire, so an old server stays update-needed while offline. + if (!(answer.capabilities ?? []).includes(DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY)) { + return 'update-needed' + } + return health === 'available' ? 'ready' : 'unavailable' +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 207651cb9a8..e507d04e9ae 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -31,9 +31,21 @@ import type { LayoutApplyReply } from '../../shared/workspace-layout/workspace-l import { rekeyMovedPaneOwners } from './moved-pane-owner-rekey' import { applyWindowLayoutCommand, type WindowLayoutCommand } from './runtime-layout-apply' import type { TerminalCreateOptions } from './runtime-terminal-contracts' +import { PairedServerRowsProducer } from './sidebar-inputs/paired-server-rows-producer' +import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-session-contracts' +import { getHostDisplayLabelOverrides } from '../../shared/host-setting-overrides' // oxlint-disable-next-line typescript/no-unsafe-declaration-merging -- SAFETY: The existing Linear installer supplies the canonical prototype surface. class OrcaRuntimeService extends OrcaRuntimeWithWorkspaceActivityFacts { + // Idle until relay hosts attach. The fallback refresh runs while a window shows the sidebar. + readonly pairedServerRows = new PairedServerRowsProducer({ + hasDemand: () => + this.authoritativeWindowId !== null && + this.authoritativeWindowId !== HEADLESS_RUNTIME_WINDOW_ID && + this.store?.getUI?.().sidebarOpen !== false, + hostLabelOverrides: () => getHostDisplayLabelOverrides(this.getClientSettings()) + }) + private async selectReferenceCatalogWorkspace(selector: string, cwd?: string) { const catalog = listReferenceWorkspaces( this.requireStore(), diff --git a/src/main/runtime/rpc/methods/sidebar-inputs.ts b/src/main/runtime/rpc/methods/sidebar-inputs.ts index 5c21bf710b4..1a5cbb7fdff 100644 --- a/src/main/runtime/rpc/methods/sidebar-inputs.ts +++ b/src/main/runtime/rpc/methods/sidebar-inputs.ts @@ -21,5 +21,11 @@ export const SIDEBAR_INPUT_METHODS = [ sshStateOf: getRegisteredSshState, wslStatuses: getWslHostService()?.getStatuses() ?? [] }) + }), + defineMethod({ + name: 'sidebarInputs.pairedServerRows', + permission: 'workspace', + params: null, + handler: async (_params, { runtime }) => runtime.pairedServerRows.snapshot() }) ] diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index 0b7f3b033b1..7a8a3d00e00 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -102,9 +102,12 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { nativeChatDefault: (agent) => isStructuredNativeChatDefaultFor(this.runtime, agent) }) : null + this.detachPairedServerRows?.() + this.detachPairedServerRows = hosts ? this.runtime.pairedServerRows.attach(hosts) : null this.mobileRelayHostCatalog = hosts ? new MobileRelayHostCatalog({ hosts, + serverRows: this.runtime.pairedServerRows, hostLabelOverrides: () => getHostDisplayLabelOverrides(this.runtime.getClientSettings()), getWorkspaceSession: (hostId) => this.runtime.getWorkspaceSessionForHost(hostId) }) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts index d5e5b8610dd..053dc622710 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts @@ -43,6 +43,8 @@ export class RuntimeRpcShutdown extends RuntimeRpcMobilePairing { } this.mobileSocketWiring?.dispose() this.mobileSocketWiring = null + this.detachPairedServerRows?.() + this.detachPairedServerRows = null this.detachWebSocketWiring = null const stopResults = await Promise.allSettled( transports.map(async (transport) => transport.stop()) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts index c91932ac0ff..d949123815b 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts @@ -78,6 +78,7 @@ export class RuntimeRpcState { // Why nullable: launch sets both for every runtime process; in-process test servers may not. protected mobileDesktopRelay: MobileDesktopRelay | null = null protected mobileRelayHostCatalog: MobileRelayHostCatalog | null = null + protected detachPairedServerRows: (() => void) | null = null // Why: lets an automatic mint install a provider whose launch-time construction failed. protected mobileRelayPairingProviderInstaller: (() => Promise) | null = null protected mobileRelayPairingOfferQueue: Promise = Promise.resolve() diff --git a/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts new file mode 100644 index 00000000000..0485516e6be --- /dev/null +++ b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts @@ -0,0 +1,404 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RUNTIME_PROTOCOL_VERSION } from '../../../shared/protocol-version' +import { + runtimeEnvironmentStatusFromSnapshot, + type RuntimeHostStatusSnapshot +} from '../../../shared/runtime-host-status' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { SshConnectionState } from '../../../shared/ssh-types' +import type { MobileDesktopRelayHosts } from '../mobile-desktop-relay/mobile-desktop-relay-hosts' +import { FALLBACK_REFRESH_MS, PairedServerRowsProducer } from './paired-server-rows-producer' + +function snapshot(environmentId: string, live: boolean): RuntimeHostStatusSnapshot { + return { + environmentId, + pairingRevision: 1, + sequence: 1, + checkedAt: 1, + status: { + runtimeId: 'runtime-a', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION + }, + verification: live ? 'verified' : 'unavailable', + transport: live ? 'ready' : 'disconnected' + } +} + +function psReply(rows: Record[]): RuntimeRpcResponse { + return { id: 'ps', ok: true, result: { worktrees: rows }, _meta: { runtimeId: 'runtime-a' } } +} + +function row(worktreeId: string, extra: Record = {}) { + return { + worktreeId, + repoId: 'repo', + path: `/srv/${worktreeId}`, + branch: 'main', + displayName: worktreeId, + isArchived: false, + isPinned: false, + ...extra + } +} + +function fakeHosts(servers: { id: string; live: boolean }[]) { + const state = { servers, pairingRevision: 1 } + const statusListeners = new Set<() => void>() + const retiredListeners = new Set<(environmentId: string) => void>() + const watches = new Map< + string, + { onChange: () => void; onClose: () => void; closed: boolean; ended: boolean } + >() + const call = vi.fn() + const hosts: MobileDesktopRelayHosts = { + list: () => ({ + environments: state.servers.map(({ id }) => ({ + id, + name: id, + pairingRevision: state.pairingRevision, + runtimeId: 'runtime-a' + })), + statusByEnvironmentId: new Map( + state.servers.map(({ id, live }) => [ + id, + runtimeEnvironmentStatusFromSnapshot(snapshot(id, live)) + ]) + ), + sshTargetLabels: new Map(), + sshConnectionStates: new Map() + }), + resolve: async () => null, + call, + hostsBrowserPage: () => false, + onEnvironmentRetired: (listener) => { + retiredListeners.add(listener) + return () => retiredListeners.delete(listener) + }, + onStatusChanged: (listener) => { + statusListeners.add(listener) + return () => statusListeners.delete(listener) + }, + watchChanges: (host, _expected, callbacks) => { + const watch = { ...callbacks, closed: false, ended: false } + watches.set(host.environmentId, watch) + return { + close: () => { + watch.closed = true + }, + current: () => !watch.closed && !watch.ended + } + } + } + return { + hosts, + call, + state, + watches, + publishStatus: () => statusListeners.forEach((listener) => listener()), + retire: (environmentId: string) => + retiredListeners.forEach((listener) => listener(environmentId)) + } +} + +function rowsOf(producer: PairedServerRowsProducer, hostId: string) { + return producer.snapshot().servers.find((server) => server.hostId === hostId) +} + +describe('PairedServerRowsProducer', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('populates from a server in contact once its change stream is ready', async () => { + const { hosts, call, watches } = fakeHosts([{ id: 'box', live: true }]) + call.mockResolvedValue(psReply([row('repo::/a', { workspaceKind: 'git' })])) + const producer = new PairedServerRowsProducer({ + hasDemand: () => true, + hostLabelOverrides: () => new Map() + }) + producer.attach(hosts) + expect(rowsOf(producer, 'runtime:box')).toEqual({ + hostId: 'runtime:box', + freshness: 'unverifiable', + host: { label: 'box', health: 'available', compatibility: 'ok', pairedDeviceId: null }, + rows: [] + }) + + watches.get('box')?.onChange() + await vi.advanceTimersByTimeAsync(0) + + expect(call).toHaveBeenCalledWith( + { environmentId: 'box' }, + 'worktree.ps', + { limit: 10_000, supportsWorktreeVisibilitySourceDefaults: true }, + { timeoutMs: 5_000, expected: { pairingRevision: 1, runtimeId: 'runtime-a' } } + ) + expect(rowsOf(producer, 'runtime:box')).toEqual({ + hostId: 'runtime:box', + freshness: 'live', + host: { label: 'box', health: 'available', compatibility: 'ok', pairedDeviceId: null }, + rows: [ + { + key: 'runtime:box|repo::/a', + kind: 'git', + worktreeId: 'repo::/a', + repoId: 'repo', + path: '/srv/repo::/a', + branch: 'main', + displayName: 'repo::/a', + isArchived: false, + isPinned: false + } + ] + }) + }) + + it('refetches on a change event and drops workspaces the server deleted', async () => { + const { hosts, call, watches } = fakeHosts([{ id: 'box', live: true }]) + call.mockResolvedValueOnce(psReply([row('a'), row('b')])) + const producer = new PairedServerRowsProducer({ + hasDemand: () => true, + hostLabelOverrides: () => new Map() + }) + producer.attach(hosts) + watches.get('box')?.onChange() + await vi.advanceTimersByTimeAsync(0) + const before = producer.snapshot().revision + + call.mockResolvedValueOnce(psReply([row('b')])) + watches.get('box')?.onChange() + await vi.advanceTimersByTimeAsync(0) + + expect(rowsOf(producer, 'runtime:box')?.rows.map((entry) => entry.worktreeId)).toEqual(['b']) + expect(producer.snapshot().revision).toBeGreaterThan(before) + }) + + it('runs one more fetch for a change that lands while a fetch is in flight', async () => { + const { hosts, call, watches } = fakeHosts([{ id: 'box', live: true }]) + let answer: (response: RuntimeRpcResponse) => void = () => {} + call.mockReturnValueOnce(new Promise((resolve) => (answer = resolve))) + call.mockResolvedValueOnce(psReply([row('new')])) + const producer = new PairedServerRowsProducer({ + hasDemand: () => true, + hostLabelOverrides: () => new Map() + }) + producer.attach(hosts) + watches.get('box')?.onChange() + watches.get('box')?.onChange() + watches.get('box')?.onChange() + answer(psReply([row('old')])) + await vi.advanceTimersByTimeAsync(0) + + expect(call).toHaveBeenCalledTimes(2) + expect(rowsOf(producer, 'runtime:box')?.rows.map((entry) => entry.worktreeId)).toEqual(['new']) + }) + + it('keeps the last rows as unverifiable when the server loses contact or stops answering', async () => { + const { hosts, call, watches, state, publishStatus } = fakeHosts([{ id: 'box', live: true }]) + call.mockResolvedValueOnce(psReply([row('a')])) + const producer = new PairedServerRowsProducer({ + hasDemand: () => true, + hostLabelOverrides: () => new Map() + }) + producer.attach(hosts) + watches.get('box')?.onChange() + await vi.advanceTimersByTimeAsync(0) + + call.mockRejectedValueOnce(new Error('timeout')) + await producer.refresh('box') + expect(rowsOf(producer, 'runtime:box')).toMatchObject({ + freshness: 'unverifiable', + rows: [{ worktreeId: 'a' }] + }) + + call.mockResolvedValueOnce(psReply([row('a')])) + await producer.refresh('box') + expect(rowsOf(producer, 'runtime:box')?.freshness).toBe('live') + + state.servers = [{ id: 'box', live: false }] + publishStatus() + expect(watches.get('box')?.closed).toBe(true) + expect(rowsOf(producer, 'runtime:box')).toMatchObject({ + freshness: 'unverifiable', + rows: [{ worktreeId: 'a' }] + }) + }) + + it('watches again and refetches when the server comes back into contact', async () => { + const { hosts, call, watches, state, publishStatus } = fakeHosts([{ id: 'box', live: false }]) + call.mockResolvedValue(psReply([row('a')])) + const producer = new PairedServerRowsProducer({ + hasDemand: () => true, + hostLabelOverrides: () => new Map() + }) + producer.attach(hosts) + expect(watches.has('box')).toBe(false) + + state.servers = [{ id: 'box', live: true }] + publishStatus() + watches.get('box')?.onChange() + await vi.advanceTimersByTimeAsync(0) + + expect(rowsOf(producer, 'runtime:box')?.freshness).toBe('live') + }) + + it("drops a removed or re-paired server's rows, even ones a fetch delivers afterwards", async () => { + const { hosts, call, watches, state, retire } = fakeHosts([{ id: 'box', live: true }]) + let answer: (response: RuntimeRpcResponse) => void = () => {} + call.mockReturnValueOnce(new Promise((resolve) => (answer = resolve))) + const producer = new PairedServerRowsProducer({ + hasDemand: () => true, + hostLabelOverrides: () => new Map() + }) + producer.attach(hosts) + watches.get('box')?.onChange() + + state.pairingRevision = 2 + retire('box') + answer(psReply([row('old-pairing')])) + await vi.advanceTimersByTimeAsync(0) + expect(rowsOf(producer, 'runtime:box')).toEqual({ + hostId: 'runtime:box', + freshness: 'unverifiable', + host: { label: 'box', health: 'available', compatibility: 'ok', pairedDeviceId: null }, + rows: [] + }) + + state.servers = [] + retire('box') + expect(producer.snapshot().servers).toEqual([]) + }) + + it('refreshes every server in contact every 30 s, only while the list is showing', async () => { + const { hosts, call } = fakeHosts([ + { id: 'box', live: true }, + { id: 'down', live: false } + ]) + call.mockResolvedValue(psReply([])) + let showing = false + const producer = new PairedServerRowsProducer({ + hasDemand: () => showing, + hostLabelOverrides: () => new Map() + }) + const detach = producer.attach(hosts) + + await vi.advanceTimersByTimeAsync(FALLBACK_REFRESH_MS) + expect(call).not.toHaveBeenCalled() + + showing = true + await vi.advanceTimersByTimeAsync(FALLBACK_REFRESH_MS) + expect(call).toHaveBeenCalledTimes(1) + expect(call.mock.calls[0]?.[0]).toEqual({ environmentId: 'box' }) + + detach() + await vi.advanceTimersByTimeAsync(FALLBACK_REFRESH_MS) + expect(call).toHaveBeenCalledTimes(1) + }) + + it("keys rows by host, so two servers' same bare id never collide, and fills older servers' gaps", async () => { + const { hosts, call, watches } = fakeHosts([ + { id: 'one', live: true }, + { id: 'two', live: true } + ]) + // An older server sends no workspaceKind or displayName; a newer one an arm this build lacks. + call.mockResolvedValueOnce( + psReply([{ worktreeId: 'same', repoId: 'r', path: '/p', branch: 'b' }]) + ) + call.mockResolvedValueOnce( + psReply([row('same', { workspaceKind: 'future-kind' }), { path: '/no-id' }]) + ) + const producer = new PairedServerRowsProducer({ + hasDemand: () => true, + hostLabelOverrides: () => new Map() + }) + producer.attach(hosts) + watches.get('one')?.onChange() + await vi.advanceTimersByTimeAsync(0) + watches.get('two')?.onChange() + await vi.advanceTimersByTimeAsync(0) + + expect(rowsOf(producer, 'runtime:one')?.rows).toEqual([ + { + key: 'runtime:one|same', + kind: 'git', + worktreeId: 'same', + repoId: 'r', + path: '/p', + branch: 'b', + displayName: '', + isArchived: false, + isPinned: false + } + ]) + expect(rowsOf(producer, 'runtime:two')?.rows.map((entry) => [entry.key, entry.kind])).toEqual([ + ['runtime:two|same', 'git'] + ]) + }) + + it('serves raw rows only under the pairing they were fetched for', async () => { + const { hosts, call, watches } = fakeHosts([{ id: 'box', live: true }]) + call.mockResolvedValueOnce(psReply([row('a', { preview: 'kept as sent' })])) + const producer = new PairedServerRowsProducer({ + hasDemand: () => true, + hostLabelOverrides: () => new Map() + }) + producer.attach(hosts) + watches.get('box')?.onChange() + await vi.advanceTimersByTimeAsync(0) + + expect(producer.rows('box', '1\0runtime-a')).toEqual([ + { ...row('a', { preview: 'kept as sent' }), hostId: 'runtime:box' } + ]) + expect(producer.rows('box', '2\0runtime-a')).toBeNull() + }) + it('reads unverifiable after contact returns until a fetch answers again', async () => { + const { hosts, call, watches, state, publishStatus } = fakeHosts([{ id: 'box', live: true }]) + let answer: (response: RuntimeRpcResponse) => void = () => {} + call.mockResolvedValueOnce(psReply([row('a')])) + call.mockReturnValueOnce(new Promise((resolve) => (answer = resolve))) + const producer = new PairedServerRowsProducer({ + hasDemand: () => true, + hostLabelOverrides: () => new Map() + }) + producer.attach(hosts) + watches.get('box')?.onChange() + await vi.advanceTimersByTimeAsync(0) + + state.servers = [{ id: 'box', live: false }] + publishStatus() + state.servers = [{ id: 'box', live: true }] + publishStatus() + watches.get('box')?.onChange() + expect(rowsOf(producer, 'runtime:box')?.freshness).toBe('unverifiable') + answer(psReply([row('a')])) + await vi.advanceTimersByTimeAsync(0) + expect(rowsOf(producer, 'runtime:box')?.freshness).toBe('live') + }) + + it('opens a new watch once the old one ends with its transport, even without a close notice', () => { + const { hosts, watches, publishStatus } = fakeHosts([{ id: 'box', live: true }]) + const producer = new PairedServerRowsProducer({ + hasDemand: () => true, + hostLabelOverrides: () => new Map() + }) + producer.attach(hosts) + const first = watches.get('box') + publishStatus() + expect(watches.get('box')).toBe(first) + + if (first) { + first.ended = true + } + publishStatus() + expect(watches.get('box')).not.toBe(first) + expect(first?.closed).toBe(true) + }) +}) diff --git a/src/main/runtime/sidebar-inputs/paired-server-rows-producer.ts b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.ts new file mode 100644 index 00000000000..080d0783368 --- /dev/null +++ b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.ts @@ -0,0 +1,262 @@ +import { z } from 'zod' +import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { describePairedServerHosts } from '../../../shared/paired-server-hosts' +import type { MobileRelayServerWorktreeRow } from '../../../shared/mobile-relay-hosts-contract' +import { + normalizePairedServerRow, + type PairedServerRowsSnapshot +} from '../../../shared/sidebar-list/paired-server-rows' +import type { + MobileDesktopRelayHostListing, + MobileDesktopRelayHosts, + MobileDesktopRelayHostWatch +} from '../mobile-desktop-relay/mobile-desktop-relay-hosts' +import { + describeRelayHosts, + relayHostFence +} from '../mobile-desktop-relay/mobile-relay-host-descriptions' + +// Why the phone's own full-catalog limit: the server's default page is 200 and nothing pages on. +const SERVER_WORKTREE_LIMIT = 10_000 +// Why well under the phone's 30s request timeout: a hung server must answer with its last rows. +export const SERVER_FETCH_TIMEOUT_MS = 5_000 +// Owner-approved fallback for changes a server announces no event for (status-only, older servers). +export const FALLBACK_REFRESH_MS = 30_000 + +const WorktreePsReplySchema = z.object({ worktrees: z.array(z.looseObject({})) }) + +type ServerEntry = { + environmentId: string + fence: string + identity: Pick + inContact: boolean + /** Null until a fetch under this pairing answered. */ + rows: MobileRelayServerWorktreeRow[] | null + /** The rows are the server's answer since it last came into contact; else unverifiable. */ + confirmed: boolean + watch: MobileDesktopRelayHostWatch | null + refreshing: Promise | null + refreshAgain: boolean +} + +/** + * The desktop's one live set of paired-server workspace rows, needing no window. A server is + * refetched when it comes into contact, on each of its change events, when a reader asks, and + * every 30 s while the list is showing. A server's last rows read unverifiable once it loses + * contact, until a fetch answers again. Only removing or re-pairing a server drops them; a fetch + * that answers replaces them, deletions included. + */ +export class PairedServerRowsProducer { + private readonly servers = new Map() + private hosts: MobileDesktopRelayHosts | null = null + private revision = 0 + + constructor( + private readonly options: { + hasDemand: () => boolean + hostLabelOverrides: () => ReadonlyMap + } + ) {} + + attach(hosts: MobileDesktopRelayHosts): () => void { + this.hosts = hosts + const stopStatus = hosts.onStatusChanged(() => this.sync()) + const stopRetired = hosts.onEnvironmentRetired(() => this.sync()) + const fallback = setInterval(() => { + if (this.options.hasDemand()) { + for (const entry of this.servers.values()) { + if (entry.inContact) { + void this.refresh(entry.environmentId) + } + } + } + }, FALLBACK_REFRESH_MS) + fallback.unref?.() + this.sync() + return () => { + clearInterval(fallback) + stopStatus() + stopRetired() + for (const entry of this.servers.values()) { + entry.watch?.close() + } + this.servers.clear() + this.hosts = null + this.revision += 1 + } + } + + /** Re-reads the configured servers: drops removed or re-paired ones, watches those in contact. */ + sync(): void { + const hosts = this.hosts + if (!hosts) { + return + } + const fences = new Map( + hosts.list().environments.map((environment) => [environment.id, relayHostFence(environment)]) + ) + for (const [environmentId, entry] of this.servers) { + if (fences.get(environmentId) !== entry.fence) { + entry.watch?.close() + this.servers.delete(environmentId) + this.revision += 1 + } + } + // Why the shown hosts: a managed server reached over its SSH host's relay is not in contact here. + const shown = new Map(describeRelayHosts(hosts).map((host) => [host.environmentId, host])) + for (const host of shown.values()) { + if (!this.servers.has(host.environmentId)) { + this.servers.set(host.environmentId, { + environmentId: host.environmentId, + fence: relayHostFence(host.identity), + identity: host.identity, + inContact: false, + rows: null, + confirmed: false, + watch: null, + refreshing: null, + refreshAgain: false + }) + this.revision += 1 + } + } + for (const entry of this.servers.values()) { + const inContact = shown.get(entry.environmentId)?.health === 'available' + if (inContact !== entry.inContact) { + entry.inContact = inContact + entry.confirmed = false + this.revision += 1 + } + // Why current(): a replaced transport can end the stream without telling it. + if (inContact && !entry.watch?.current()) { + entry.watch?.close() + this.watch(hosts, entry) + } else if (!inContact && entry.watch) { + entry.watch.close() + entry.watch = null + } + } + } + + /** Fetches now, or joins the fetch in flight. */ + refresh(environmentId: string): Promise { + const entry = this.servers.get(environmentId) + if (!entry || !this.hosts) { + return Promise.resolve() + } + if (!entry.refreshing) { + const refreshing = this.fetchUntilCurrent(this.hosts, entry).finally(() => { + entry.refreshing = null + }) + entry.refreshing = refreshing + } + return entry.refreshing + } + + /** The server's raw rows as last fetched under this pairing; null when none were. */ + rows(environmentId: string, fence: string): readonly MobileRelayServerWorktreeRow[] | null { + const entry = this.servers.get(environmentId) + return entry && entry.fence === fence ? entry.rows : null + } + + snapshot(): PairedServerRowsSnapshot { + const shown = new Map( + (this.hosts + ? describePairedServerHosts({ + ...this.hosts.list(), + hostLabelOverrides: this.options.hostLabelOverrides() + }) + : [] + ).map(({ environmentId, label, health, compatibility, pairedDeviceId }) => [ + environmentId, + { label, health, compatibility, pairedDeviceId } + ]) + ) + return { + revision: this.revision, + servers: [...this.servers.values()].map((entry) => { + const hostId = toRuntimeExecutionHostId(entry.environmentId) + return { + hostId, + freshness: entry.inContact && entry.confirmed ? 'live' : 'unverifiable', + host: shown.get(entry.environmentId) ?? null, + rows: (entry.rows ?? []).flatMap((row) => normalizePairedServerRow(hostId, row) ?? []) + } + }) + } + } + + private watch(hosts: MobileDesktopRelayHosts, entry: ServerEntry): void { + const current = (): boolean => this.servers.get(entry.environmentId) === entry + // Ready fires on every (re)connect, so contact and a missed-event replay both count as changes. + const watch = hosts.watchChanges({ environmentId: entry.environmentId }, entry.identity, { + onChange: () => { + if (current()) { + // A fetch already in flight may predate this change, so one more runs after it. + entry.refreshAgain ||= entry.refreshing !== null + void this.refresh(entry.environmentId) + } + }, + onClose: () => { + if (current() && entry.watch === watch) { + entry.watch = null + } + } + }) + entry.watch = watch + } + + private unconfirm(entry: ServerEntry): void { + if (entry.confirmed) { + entry.confirmed = false + this.revision += 1 + } + } + + private async fetchUntilCurrent(hosts: MobileDesktopRelayHosts, entry: ServerEntry) { + do { + entry.refreshAgain = false + await this.fetch(hosts, entry) + } while (entry.refreshAgain && this.servers.get(entry.environmentId) === entry) + } + + private async fetch(hosts: MobileDesktopRelayHosts, entry: ServerEntry): Promise { + let response: Awaited> | null = null + try { + response = await hosts.call( + { environmentId: entry.environmentId }, + 'worktree.ps', + { limit: SERVER_WORKTREE_LIMIT, supportsWorktreeVisibilitySourceDefaults: true }, + // Why the transport bound too: it ends the hung call, so the next request fetches afresh. + { timeoutMs: SERVER_FETCH_TIMEOUT_MS, expected: entry.identity } + ) + } catch { + // An unreachable server keeps its last rows. + } + // Why: an answer that lands after a re-pair or removal belongs to the old pairing. + if (this.servers.get(entry.environmentId) !== entry) { + return + } + const reply = response?.ok ? WorktreePsReplySchema.safeParse(response.result) : null + if (!reply?.success) { + this.unconfirm(entry) + return + } + entry.rows = stampServerWorktreeRows(entry.environmentId, reply.data.worktrees) + entry.confirmed = entry.inContact + // Every answer moves the revision: rows carry activity that changes without a catalog event. + this.revision += 1 + } +} + +/** + * A server's rows name hosts relative to that server (`local`, its own `ssh:` targets). Like the + * desktop sidebar, the phone shows every one of them under the server, so all become its host id. + */ +function stampServerWorktreeRows( + environmentId: string, + rows: readonly Row[] +): (Row & { hostId: `runtime:${string}` })[] { + const hostId = toRuntimeExecutionHostId(environmentId) + return rows.map((row) => ({ ...row, hostId })) +} diff --git a/src/renderer/src/assets/mobile-page.css b/src/renderer/src/assets/mobile-page.css index 8aa6e2ae659..f4f458659fd 100644 --- a/src/renderer/src/assets/mobile-page.css +++ b/src/renderer/src/assets/mobile-page.css @@ -1163,12 +1163,7 @@ flex-direction: column; background: var(--m-bg-base); color: var(--m-text-primary); - font-family: - 'Geist', - -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - sans-serif; + font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 13px; box-sizing: border-box; /* Why: the phone frame is viewport-fit now; the preview content must stay diff --git a/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts index 19b4187c564..716fd91368c 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts @@ -30,13 +30,19 @@ export function useNativeChatPasteLifetime(args: { options: { deferPreview: boolean; canShow: () => boolean } ) => { id: string | null; reveal: () => void } /** Register the operation for the native clipboard's picture; `reveal` previews its thumbnail. */ - startClipboardImageChip: (owner: NativeChatAttachmentOwner, pendingName?: string) => { + startClipboardImageChip: ( + owner: NativeChatAttachmentOwner, + pendingName?: string + ) => { id: string | null reveal: () => void } /** One chip per file a macOS paste may attach. `reveal` previews the first as the clipboard's * picture; `transfer` hands the chips still in the draft to the file intake to settle. */ - startClipboardCandidateChips: (owner: NativeChatAttachmentOwner, paths: string[]) => { + startClipboardCandidateChips: ( + owner: NativeChatAttachmentOwner, + paths: string[] + ) => { reserved: NativeChatReservedAttachment[] reveal: () => void transfer: () => NativeChatReservedAttachment[] diff --git a/src/renderer/src/lib/sidebar-shadow/paired-server-rows-shadow.test.ts b/src/renderer/src/lib/sidebar-shadow/paired-server-rows-shadow.test.ts new file mode 100644 index 00000000000..73cd384155d --- /dev/null +++ b/src/renderer/src/lib/sidebar-shadow/paired-server-rows-shadow.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { makeFolderWorkspace, makeWorktree } from '@/components/worktree-jump-palette-test-fixtures' +import { readWindowPairedServerRows } from './paired-server-rows-shadow' + +describe('readWindowPairedServerRows', () => { + it("keys each listed server's rows by host, so a local row with the same bare id stays apart", () => { + const rows = readWindowPairedServerRows( + { + worktreesByRepo: { + 'repo-1': [ + makeWorktree('same', 'On the server', { hostId: 'runtime:box', isPinned: true }), + makeWorktree('same', 'Local', { hostId: 'local' }), + makeWorktree('other', 'Unlisted server', { hostId: 'runtime:gone' }) + ] + }, + folderWorkspaces: [makeFolderWorkspace({ executionHostId: 'runtime:box', name: 'Notes' })] + }, + new Set(['runtime:box']) + ) + expect(Object.fromEntries(rows)).toEqual({ + 'runtime:box|same': { + key: 'runtime:box|same', + worktreeId: 'same', + repoId: 'repo-1', + kind: 'git', + path: '/tmp/same', + branch: 'refs/heads/main', + displayName: 'On the server', + isArchived: false, + isPinned: true + }, + 'runtime:box|folder:folder-1': expect.objectContaining({ + kind: 'folder', + worktreeId: 'folder:folder-1', + displayName: 'Notes' + }) + }) + }) +}) diff --git a/src/renderer/src/lib/sidebar-shadow/paired-server-rows-shadow.ts b/src/renderer/src/lib/sidebar-shadow/paired-server-rows-shadow.ts new file mode 100644 index 00000000000..0b53e4ef5e0 --- /dev/null +++ b/src/renderer/src/lib/sidebar-shadow/paired-server-rows-shadow.ts @@ -0,0 +1,144 @@ +import { useAppStore, type AppState } from '@/store' +import { getAllWorktreesFromState } from '@/store/selectors' +import { + folderWorkspaceRepoId, + folderWorkspaceToWorktree +} from '../../../../shared/folder-workspace-worktree' +import { + PairedServerRowsSnapshotSchema, + type PairedServerHostFacts, + type PairedServerWorkspaceRow +} from '../../../../shared/sidebar-list/paired-server-rows' +import { describePairedServerHosts } from '../../../../shared/paired-server-hosts' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' +import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' +import type { Worktree } from '../../../../shared/worktree/types' +import { defineSidebarShadowDomain, type SidebarShadowMainRead } from './sidebar-shadow-comparison' + +/** The paired-server rows the window fetched itself, keyed by host-qualified id, for these hosts. */ +export function readWindowPairedServerRows( + state: Pick, + hostIds: ReadonlySet +): Map { + const rows = new Map() + const add = (worktree: Worktree, kind: PairedServerWorkspaceRow['kind']): void => { + if (!worktree.hostId || !hostIds.has(worktree.hostId)) { + return + } + const key = getWorktreeHostIdentity(worktree) + rows.set(key, { + key, + worktreeId: worktree.id, + repoId: worktree.repoId, + kind, + path: worktree.path, + branch: worktree.branch, + displayName: worktree.displayName, + isArchived: worktree.isArchived, + isPinned: worktree.isPinned + }) + } + for (const worktree of getAllWorktreesFromState(state)) { + add(worktree, 'git') + } + for (const folder of state.folderWorkspaces ?? []) { + add(folderWorkspaceToWorktree(folder), 'folder') + } + return rows +} + +function windowListedRepoIds( + state: Pick +): Set { + return new Set([ + ...Object.keys(state.worktreesByRepo), + ...(state.folderWorkspaces ?? []).map((folder) => folderWorkspaceRepoId(folder.projectGroupId)) + ]) +} + +async function readMainSnapshot() { + const response = await window.api.runtime.call({ method: 'sidebarInputs.pairedServerRows' }) + const parsed = response.ok ? PairedServerRowsSnapshotSchema.safeParse(response.result) : null + return parsed?.success ? parsed.data : null +} + +export const pairedServerRowsShadow = defineSidebarShadowDomain< + PairedServerWorkspaceRow, + SidebarShadowMainRead & { liveHostIds: ReadonlySet } +>({ + name: 'paired-server-rows', + readMain: async () => { + const snapshot = await readMainSnapshot() + if (!snapshot) { + return null + } + // Only servers main last reached: an unverifiable server's rows are its last, not its current. + const live = snapshot.servers.filter((server) => server.freshness === 'live') + // Only projects the window has listed: it fetches a server's new project after main does. + const listed = windowListedRepoIds(useAppStore.getState()) + return { + revision: snapshot.revision, + values: new Map( + live.flatMap((server) => + server.rows.flatMap((row) => (listed.has(row.repoId) ? [[row.key, row] as const] : [])) + ) + ), + liveHostIds: new Set(live.map((server) => server.hostId)) + } + }, + readWindow: (main) => readWindowPairedServerRows(useAppStore.getState(), main.liveHostIds), + equals: (window, main) => JSON.stringify(window ?? null) === JSON.stringify(main ?? null) +}) + +/** The window's header facts for these servers, from its own status, environment and label state. */ +export function readWindowPairedServerHosts( + state: Pick< + AppState, + | 'runtimeEnvironments' + | 'runtimeStatusByEnvironmentId' + | 'sshTargetLabels' + | 'sshConnectionStates' + | 'settings' + >, + hostIds: ReadonlySet +): Map { + const hosts = describePairedServerHosts({ + environments: state.runtimeEnvironments, + statusByEnvironmentId: state.runtimeStatusByEnvironmentId, + sshTargetLabels: state.sshTargetLabels, + sshConnectionStates: state.sshConnectionStates, + hostLabelOverrides: getHostDisplayLabelOverrides(state.settings) + }) + return new Map( + hosts.flatMap(({ hostId, label, health, compatibility, pairedDeviceId }) => + hostIds.has(hostId) + ? [[hostId, { label, health, compatibility, pairedDeviceId }] as const] + : [] + ) + ) +} + +export const pairedServerHostsShadow = defineSidebarShadowDomain< + PairedServerHostFacts, + SidebarShadowMainRead & { hostIds: ReadonlySet } +>({ + name: 'paired-server-hosts', + readMain: async () => { + const snapshot = await readMainSnapshot() + if (!snapshot) { + return null + } + return { + revision: snapshot.revision, + values: new Map( + snapshot.servers.flatMap((server) => + server.host ? [[server.hostId, server.host] as const] : [] + ) + ), + // Main lists only servers a phone may reach, never this desktop's own WSL hosts. + hostIds: new Set(snapshot.servers.map((server) => server.hostId)) + } + }, + readWindow: (main) => readWindowPairedServerHosts(useAppStore.getState(), main.hostIds), + equals: (window, main) => JSON.stringify(window ?? null) === JSON.stringify(main ?? null) +}) diff --git a/src/renderer/src/lib/sidebar-shadow/sidebar-shadow-comparison.ts b/src/renderer/src/lib/sidebar-shadow/sidebar-shadow-comparison.ts index 76b7fab0a04..39288bfc02a 100644 --- a/src/renderer/src/lib/sidebar-shadow/sidebar-shadow-comparison.ts +++ b/src/renderer/src/lib/sidebar-shadow/sidebar-shadow-comparison.ts @@ -1,15 +1,19 @@ import { e2eConfig } from '@/lib/e2e-config' +/** Facts read on demand carry no revision. */ +export type SidebarShadowMainRead = { revision?: number; values: ReadonlyMap } + /** * One sidebar input main is taking over: what the window derives itself next to what main * publishes, keyed by workspace. Both reads cover the same scope; another producer's rows are * left out of both. */ -export type SidebarShadowDomain = { +export type SidebarShadowDomain> = { name: string - readWindow: () => ReadonlyMap - /** Null when this host does not publish the domain (an older host); facts read on demand carry no revision. */ - readMain: () => Promise<{ revision?: number; values: ReadonlyMap } | null> + /** Given main's read, so a domain can scope the window's side to what main covers. */ + readWindow: (main: Main) => ReadonlyMap + /** Null when this host does not publish the domain (an older host). */ + readMain: () => Promise
equals: (window: Value | undefined, main: Value | undefined) => boolean } @@ -32,9 +36,10 @@ const CHECK_INTERVAL_MS = 1_500 * Records settled disagreements only: a key is recorded once both sides held the same differing * values across a whole check interval, so propagation lag between window and main never counts. */ -export function defineSidebarShadowDomain( - domain: SidebarShadowDomain -): SidebarShadowCheck { +export function defineSidebarShadowDomain< + Value, + Main extends SidebarShadowMainRead = SidebarShadowMainRead +>(domain: SidebarShadowDomain): SidebarShadowCheck { let pending = new Map() const recorded = new Set() return async (record) => { @@ -42,7 +47,7 @@ export function defineSidebarShadowDomain( if (!main) { return false } - const window = domain.readWindow() + const window = domain.readWindow(main) const next = new Map() for (const key of new Set([...window.keys(), ...main.values.keys()])) { const windowValue = window.get(key) ?? null diff --git a/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.test.ts b/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.test.ts index ee818e1e737..df90076de73 100644 --- a/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.test.ts +++ b/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import { readWindowWorkspaceActivityFacts } from './workspace-activity-shadow' +import { useAppStore } from '@/store' +import { makeWorktree } from '@/components/worktree-jump-palette-test-fixtures' +import { readWindowWorkspaceActivityFacts, toLocallyOwnedRows } from './workspace-activity-shadow' const NOW = 1_000_000 @@ -68,3 +70,23 @@ describe('readWindowWorkspaceActivityFacts', () => { }) }) }) + +describe('toLocallyOwnedRows', () => { + it('leaves out an id a paired server also lists, since bare-id activity names no one host', () => { + const state = { + ...useAppStore.getState(), + repos: [], + folderWorkspaces: [], + worktreesByRepo: { + 'repo-1': [ + makeWorktree('local-only', 'Local', { hostId: 'local' }), + makeWorktree('shared', 'Local twin', { hostId: 'local' }), + makeWorktree('shared', 'Server twin', { hostId: 'runtime:box' }), + makeWorktree('server-only', 'Server', { hostId: 'runtime:box' }) + ] + } + } + const values = new Map(['local-only', 'shared', 'server-only', 'unknown'].map((id) => [id, id])) + expect([...toLocallyOwnedRows(state, values).keys()]).toEqual(['local-only']) + }) +}) diff --git a/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.ts b/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.ts index 0ff16ca1b80..17c9dae1dd1 100644 --- a/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.ts +++ b/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.ts @@ -8,7 +8,7 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' -import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { IDLE_WORKSPACE_ACTIVITY, isIdleWorkspaceActivity, @@ -61,29 +61,33 @@ export function readWindowWorkspaceActivityFacts( return facts } -// The local runtime answers for the rows it owns. A paired server owns and publishes its own rows' -// facts, which the server-row producer compares; an id with no owner in the catalog is no row. +// The local runtime answers for the rows it owns; a paired server publishes its own rows' facts. +// The window keys activity by bare id, so an id a paired server also lists cannot be attributed +// to one host and is left out; an id with no owner in the catalog is no row. export function toLocallyOwnedRows( state: AppState, values: ReadonlyMap ): Map { - const worktreeById = new Map(getAllWorktreesFromState(state).map((row) => [row.id, row])) - const folderById = new Map((state.folderWorkspaces ?? []).map((folder) => [folder.id, folder])) const repoById = getRepoMapFromState(state) - const ownerOf = (id: string): ExecutionHostId | null => { - const worktree = worktreeById.get(id) - const scope = parseWorkspaceKey(id) - const hostFields = worktree - ? repoById.get(worktree.repoId) - : scope?.type === 'folder' - ? folderById.get(scope.folderWorkspaceId) - : undefined - return worktree?.hostId ?? (hostFields ? getRepoExecutionHostId(hostFields) : null) + const ownersById = new Map>() + const own = (id: string, owner: ExecutionHostId | null | undefined): void => { + if (owner) { + const owners = ownersById.get(id) ?? new Set() + owners.add(parseExecutionHostId(owner)?.kind === 'runtime' ? 'server' : 'local') + ownersById.set(id, owners) + } + } + for (const worktree of getAllWorktreesFromState(state)) { + const repo = repoById.get(worktree.repoId) + own(worktree.id, worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : null)) + } + for (const folder of state.folderWorkspaces ?? []) { + own(folderWorkspaceKey(folder.id), getRepoExecutionHostId(folder)) } const scoped = new Map() for (const [id, value] of values) { - const owner = ownerOf(id) - if (owner !== null && parseExecutionHostId(owner)?.kind !== 'runtime') { + const owners = ownersById.get(id) + if (owners?.has('local') && !owners.has('server')) { scoped.set(id, value) } } diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index fe9949cef35..c0148f81c34 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -27,6 +27,10 @@ import { installBrowserClientPageRenderer } from './components/browser-pane/brow import { installOsFileDropCancellationGuard } from './lib/os-file-drop-cancellation-guard' import { installSidebarShadowComparison } from './lib/sidebar-shadow/sidebar-shadow-comparison' import { workspaceActivityShadow } from './lib/sidebar-shadow/workspace-activity-shadow' +import { + pairedServerHostsShadow, + pairedServerRowsShadow +} from './lib/sidebar-shadow/paired-server-rows-shadow' import { desktopFactsShadow } from './lib/sidebar-shadow/desktop-facts-shadow' const disposeOsFileDropGuard = installOsFileDropCancellationGuard() @@ -37,6 +41,8 @@ installTypingLatencyDiagnostic() installAutomationHostDiagnostic() const disposeSidebarShadow = installSidebarShadowComparison([ workspaceActivityShadow, + pairedServerRowsShadow, + pairedServerHostsShadow, desktopFactsShadow ]) import.meta.hot?.dispose(disposeSidebarShadow) diff --git a/src/shared/paired-server-hosts.ts b/src/shared/paired-server-hosts.ts new file mode 100644 index 00000000000..2d30bb73131 --- /dev/null +++ b/src/shared/paired-server-hosts.ts @@ -0,0 +1,72 @@ +import { parseExecutionHostId, type ExecutionHostId } from './execution-host' +import { buildExecutionHostRegistry, type ExecutionHostHealth } from './execution-host-registry' +import { + pickerExecutionHosts, + type ManagedOrcadDeploymentSummary +} from './managed-orcad-execution-host' +import type { RuntimeCompatVerdict } from './protocol-compat' +import type { RuntimeEnvironmentSource } from './runtime-environments' +import type { RuntimeEnvironmentStatus } from './runtime-host-status' +import type { SshConnectionState } from './ssh-types' +import { getPairedDeviceIdsByEnvironment } from './workspace-creator-visibility' + +export type PairedServerHostInputs = { + environments: readonly { + id: string + name?: string | null + source?: RuntimeEnvironmentSource + orcadDeployment?: ManagedOrcadDeploymentSummary | null + pairedDeviceId?: string + }[] + statusByEnvironmentId: ReadonlyMap + sshTargetLabels: ReadonlyMap + sshConnectionStates: ReadonlyMap + hostLabelOverrides: ReadonlyMap +} + +/** A configured server's header facts: health and compatibility from the desktop's own status. */ +export type PairedServerHost = { + hostId: `runtime:${string}` + environmentId: string + label: string + health: ExecutionHostHealth + compatibility: RuntimeCompatVerdict['kind'] | null + /** The desktop's own device id on that server, which "Hide other devices" compares against. */ + pairedDeviceId: string | null +} + +/** The servers the desktop shows, as its sidebar maps them; a managed server folds into its SSH host. */ +export function describePairedServerHosts(inputs: PairedServerHostInputs): PairedServerHost[] { + const deviceIds = getPairedDeviceIdsByEnvironment( + inputs.environments, + inputs.statusByEnvironmentId + ) + const configured = new Set(inputs.environments.map((environment) => environment.id)) + return pickerExecutionHosts( + buildExecutionHostRegistry({ + repos: [], + settings: null, + hostSource: 'configured-only', + sshTargetLabels: inputs.sshTargetLabels, + sshConnectionStates: inputs.sshConnectionStates, + runtimeEnvironments: inputs.environments, + runtimeStatusByEnvironmentId: inputs.statusByEnvironmentId, + hostLabelOverrides: inputs.hostLabelOverrides + }) + ).flatMap((entry) => { + const parsed = parseExecutionHostId(entry.id) + if (parsed?.kind !== 'runtime' || !configured.has(parsed.environmentId)) { + return [] + } + return [ + { + hostId: parsed.id, + environmentId: parsed.environmentId, + label: entry.label, + health: entry.health, + compatibility: entry.compatibility?.kind ?? null, + pairedDeviceId: deviceIds.get(parsed.environmentId) ?? null + } + ] + }) +} diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index fa61e3c6902..da8fd28f489 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -1246,6 +1246,7 @@ export const RPC_PARAMS_BY_METHOD = { 'settings.updateTerminalQuickCommands': TerminalQuickCommandsUpdate, 'sidebarInputs.activityFacts': null, 'sidebarInputs.desktopFacts': null, + 'sidebarInputs.pairedServerRows': null, 'skills.beginUpload': SkillUploadBeginRequestSchema, 'skills.cancelInstall': SkillsCancelInstallParams, 'skills.cancelUpload': SkillUploadCommitRequestSchema, diff --git a/src/shared/sidebar-list/paired-server-rows.ts b/src/shared/sidebar-list/paired-server-rows.ts new file mode 100644 index 00000000000..5b30ce915b0 --- /dev/null +++ b/src/shared/sidebar-list/paired-server-rows.ts @@ -0,0 +1,73 @@ +import { z } from 'zod' +import type { ExecutionHostId } from '../execution-host' +import { composeWorktreeHostIdentity } from '../worktree/host-qualified-identity' +import { openEnum } from '../zod-salvage' + +/** One paired-server workspace as the sidebar lists it, keyed by its host-qualified id. */ +const PairedServerWorkspaceRowSchema = z.object({ + key: z.string(), + worktreeId: z.string(), + repoId: z.string(), + kind: z.enum(['git', 'folder']), + path: z.string(), + branch: z.string(), + displayName: z.string(), + isArchived: z.boolean(), + isPinned: z.boolean() +}) + +/** See PairedServerHost; health and compatibility stay open strings, as a reader only compares them. */ +const PairedServerHostFactsSchema = z.object({ + label: z.string(), + health: z.string(), + compatibility: z.string().nullable(), + pairedDeviceId: z.string().nullable() +}) + +/** Every configured server main lists rows for. Unverifiable: its last rows, not confirmed current. */ +export const PairedServerRowsSnapshotSchema = z.object({ + revision: z.number(), + servers: z.array( + z.object({ + hostId: z.string(), + freshness: z.enum(['live', 'unverifiable']), + /** Null while the desktop does not show the server (a managed one folded into its SSH host). */ + host: PairedServerHostFactsSchema.nullable(), + rows: z.array(PairedServerWorkspaceRowSchema) + }) + ) +}) + +export type PairedServerHostFacts = z.infer + +export type PairedServerWorkspaceRow = z.infer +export type PairedServerRowsSnapshot = z.infer + +// Older servers omit `workspaceKind` and may send arms this build has not heard of. +const ServerWorktreePsRowSchema = z.object({ + worktreeId: z.string().min(1), + repoId: z.string(), + workspaceKind: openEnum(['git', 'folder-workspace'], 'git').optional(), + path: z.string().catch(''), + branch: z.string().catch(''), + displayName: z.string().catch(''), + isArchived: z.boolean().catch(false), + isPinned: z.boolean().catch(false) +}) + +/** A server's own `worktree.ps` row, with defaults for what older servers omit; null without an id. */ +export function normalizePairedServerRow( + hostId: ExecutionHostId, + row: unknown +): PairedServerWorkspaceRow | null { + const parsed = ServerWorktreePsRowSchema.safeParse(row) + if (!parsed.success) { + return null + } + const { workspaceKind, ...fields } = parsed.data + return { + key: composeWorktreeHostIdentity(hostId, fields.worktreeId), + kind: workspaceKind === 'folder-workspace' ? 'folder' : 'git', + ...fields + } +} diff --git a/tests/e2e/paired-mobile-desktop-mirror-workspace-visibility.spec.ts b/tests/e2e/paired-mobile-desktop-mirror-workspace-visibility.spec.ts index fbe96de226a..29acb03f05e 100644 --- a/tests/e2e/paired-mobile-desktop-mirror-workspace-visibility.spec.ts +++ b/tests/e2e/paired-mobile-desktop-mirror-workspace-visibility.spec.ts @@ -10,6 +10,7 @@ import { z } from 'zod' import { expect, test } from './helpers/orca-app' import { NO_WORKSPACE_ROW_FILTERS } from '../../src/shared/workspace-view/workspace-row-filters' import { launchPhoneMirrorTopology } from './helpers/phone-mirror-topology' +import { expectNoSidebarShadowMismatches } from './helpers/sidebar-shadow' import type { PairedMobileSocket } from './helpers/paired-mobile-client' import { filterWorktrees, @@ -210,6 +211,7 @@ test("phone lists a server's workspaces exactly as the desktop sidebar filters t await expect.poll(desktopRowStatus, { timeout: 60_000 }).toBe('active') const awake = await expectPhoneMatchesSidebar(true) expect(awake.map((row) => path.basename(row))).toContain('from-desktop') + await expectNoSidebarShadowMismatches(desktop.page) } finally { await dispose() } diff --git a/tests/e2e/paired-server-rows-shadow.spec.ts b/tests/e2e/paired-server-rows-shadow.spec.ts new file mode 100644 index 00000000000..06c43d4f20e --- /dev/null +++ b/tests/e2e/paired-server-rows-shadow.spec.ts @@ -0,0 +1,126 @@ +/** + * A desktop paired to an `orca serve` host: main keeps the server's workspace rows itself, from the + * server's change events and with no phone asking, and they match the rows the window fetched. + */ +import { execFileSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { z } from 'zod' +import { expect, test } from './helpers/orca-app' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import { launchPairedElectronClient } from './helpers/paired-electron-client' +import { expectNoSidebarShadowMismatches } from './helpers/sidebar-shadow' +import type { Page } from '@stablyai/playwright-test' + +const SnapshotSchema = z.object({ + servers: z.array( + z.object({ + hostId: z.string(), + freshness: z.string(), + host: z.looseObject({ health: z.string() }).nullable(), + rows: z.array( + z.looseObject({ + key: z.string(), + worktreeId: z.string(), + path: z.string(), + isPinned: z.boolean() + }) + ) + }) + ) +}) +const ReposSchema = z.object({ + repos: z.array(z.looseObject({ id: z.string(), path: z.string() })) +}) +const CreateSchema = z.object({ worktree: z.looseObject({ id: z.string() }) }) + +async function mainServerRows(page: Page) { + const response = await page.evaluate(() => + window.api.runtime.call({ method: 'sidebarInputs.pairedServerRows' }) + ) + expect(response.ok).toBe(true) + const server = SnapshotSchema.parse(response.ok ? response.result : null).servers[0] + return { + freshness: server?.freshness ?? null, + health: server?.host?.health ?? null, + names: (server?.rows ?? []).map((row) => path.basename(row.path)).sort(), + pinned: (server?.rows ?? []) + .filter((row) => row.isPinned) + .map((row) => path.basename(row.path)), + keys: (server?.rows ?? []).map((row) => row.key) + } +} + +test("main keeps a paired server's rows live, deletions included, and unverifiable while it is down", async (// oxlint-disable-next-line no-empty-pattern -- this test owns its topology launch. +{}, testInfo) => { + test.setTimeout(240_000) + const serverRepo = testInfo.outputPath('server-repo') + mkdirSync(serverRepo, { recursive: true }) + writeFileSync(path.join(serverRepo, 'README.md'), 'server repo\n') + const git = (...args: string[]) => execFileSync('git', args, { cwd: serverRepo, stdio: 'pipe' }) + git('init', '-b', 'main') + git('add', 'README.md') + git('-c', 'user.name=E2E', '-c', 'user.email=e2e@test.local', 'commit', '-m', 'init') + + const host = await launchHeadlessPairedRuntimeHost({ + pairingScope: 'runtime', + pinnedServePort: true + }) + try { + const desktop = await launchPairedElectronClient(host.offer, testInfo, 'server-rows-desktop') + try { + await host.client.call('repo.add', { path: serverRepo, kind: 'git' }) + const { repos } = ReposSchema.parse((await host.client.call('repo.list', {})).result) + const repo = `id:${repos.find((entry) => entry.path === serverRepo)?.id ?? ''}` + const create = async (name: string): Promise => + CreateSchema.parse( + (await host.client.call('worktree.create', { repo, name, setupDecision: 'skip' })).result + ).worktree.id + await create('kept') + const removed = await create('removed') + + // Created on the server itself: only its change events can tell main. + await expect + .poll(async () => (await mainServerRows(desktop.page)).names, { timeout: 60_000 }) + .toEqual(expect.arrayContaining(['kept', 'removed'])) + expect( + (await mainServerRows(desktop.page)).keys.every((key) => key.startsWith('runtime:')) + ).toBe(true) + expect((await mainServerRows(desktop.page)).health).toBe('available') + await expectNoSidebarShadowMismatches(desktop.page) + + await host.client.call('worktree.set', { worktree: `id:${removed}`, isPinned: true }) + await expect + .poll(async () => (await mainServerRows(desktop.page)).pinned, { timeout: 30_000 }) + .toEqual(['removed']) + await host.client.call('worktree.rm', { + worktree: `id:${removed}`, + force: true, + runHooks: false + }) + await expect + .poll(async () => (await mainServerRows(desktop.page)).names, { timeout: 30_000 }) + .not.toContain('removed') + await expectNoSidebarShadowMismatches(desktop.page) + + // A server that stops answering keeps its last rows, never read as gone. + await host.restartServeProcess({ + betweenProcesses: async () => { + await expect + .poll(async () => (await mainServerRows(desktop.page)).freshness, { timeout: 60_000 }) + .toBe('unverifiable') + expect((await mainServerRows(desktop.page)).names).toContain('kept') + } + }) + await expect + .poll(async () => (await mainServerRows(desktop.page)).freshness, { timeout: 90_000 }) + .toBe('live') + expect((await mainServerRows(desktop.page)).names).toContain('kept') + await expectNoSidebarShadowMismatches(desktop.page) + } finally { + await desktop.dispose() + } + } finally { + await host.dispose() + } +})