feat(sidebar): main keeps one live set of paired-server rows, checked against the window in dev and test (#30027)

* feat(sidebar): main keeps one live set of paired-server rows, checked against the window in dev and test

* fix(sidebar): settle review findings; main also publishes paired-server host facts
This commit is contained in:
Jinwoo Hong
2026-10-17 01:13:34 -07:00
committed by GitHub
parent 98d8257b02
commit 03cefcb84c
29 changed files with 1450 additions and 191 deletions
@@ -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 {
@@ -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()
}
}
})
}
@@ -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<MobileDesktopRelayHost, 'environmentId'>,
expected: NonNullable<MobileDesktopRelayCallOptions['expected']>,
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
}
@@ -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)!
@@ -79,7 +79,9 @@ function relayWithPhone(
return syncResponse()
},
hostsBrowserPage: () => false,
onEnvironmentRetired: () => () => {}
onEnvironmentRetired: () => () => {},
onStatusChanged: () => () => {},
watchChanges: () => ({ close: () => {}, current: () => true })
}
const relay = new MobileDesktopRelay({
hosts,
@@ -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'))
@@ -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<string, Partial<WorkspaceSessionState>>
} = {}
) {
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 () => {
@@ -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<MobileDesktopRelayHostListing, 'pairingRevision' | 'runtimeId'>
}
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<string, CachedServerWorktrees>()
// Keyed by server identity, so a poll after a re-pair never joins the old server's fetch.
private readonly refreshing = new Map<string, Promise<void>>()
constructor(
private readonly options: {
hosts: MobileDesktopRelayHosts
/** The desktop's one live set of server rows; this catalog keeps no copy. */
serverRows: PairedServerRowsProducer
hostLabelOverrides: () => ReadonlyMap<ExecutionHostId, string>
/** 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<MobileRelayHostWorktreesResult> {
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<void> {
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<void> {
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<void>): Promise<void> {
}
}
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<Row extends { hostId?: string }>(
environmentId: string,
rows: readonly Row[]
): (Row & { hostId: `runtime:${string}` })[] {
const hostId = toRuntimeExecutionHostId(environmentId)
return rows.map((row) => ({ ...row, hostId }))
}
@@ -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<MobileDesktopRelayHostListing, 'pairingRevision' | 'runtimeId'>
}
/** The configured servers the desktop shows, with health from its own status (the sidebar's mapping). */
export function describeRelayHosts(
hosts: Pick<MobileDesktopRelayHosts, 'list'>,
hostLabelOverrides: ReadonlyMap<ExecutionHostId, string> = 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'
}
+12
View File
@@ -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(),
@@ -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()
})
]
@@ -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)
})
@@ -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())
@@ -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<unknown>) | null = null
protected mobileRelayPairingOfferQueue: Promise<void> = Promise.resolve()
@@ -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<string, unknown>[]): RuntimeRpcResponse<unknown> {
return { id: 'ps', ok: true, result: { worktrees: rows }, _meta: { runtimeId: 'runtime-a' } }
}
function row(worktreeId: string, extra: Record<string, unknown> = {}) {
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<MobileDesktopRelayHosts['call']>()
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<string, string>(),
sshConnectionStates: new Map<string, SshConnectionState>()
}),
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<unknown>) => 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<unknown>) => 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<unknown>) => 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)
})
})
@@ -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<MobileDesktopRelayHostListing, 'pairingRevision' | 'runtimeId'>
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<void> | 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<string, ServerEntry>()
private hosts: MobileDesktopRelayHosts | null = null
private revision = 0
constructor(
private readonly options: {
hasDemand: () => boolean
hostLabelOverrides: () => ReadonlyMap<ExecutionHostId, string>
}
) {}
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<void> {
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<void> {
let response: Awaited<ReturnType<MobileDesktopRelayHosts['call']>> | 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<Row extends { hostId?: string }>(
environmentId: string,
rows: readonly Row[]
): (Row & { hostId: `runtime:${string}` })[] {
const hostId = toRuntimeExecutionHostId(environmentId)
return rows.map((row) => ({ ...row, hostId }))
}
+1 -6
View File
@@ -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
@@ -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[]
@@ -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'
})
})
})
})
@@ -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<AppState, 'worktreesByRepo' | 'folderWorkspaces'>,
hostIds: ReadonlySet<string>
): Map<string, PairedServerWorkspaceRow> {
const rows = new Map<string, PairedServerWorkspaceRow>()
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<AppState, 'worktreesByRepo' | 'folderWorkspaces'>
): Set<string> {
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<PairedServerWorkspaceRow> & { liveHostIds: ReadonlySet<string> }
>({
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<string>
): Map<string, PairedServerHostFacts> {
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<PairedServerHostFacts> & { hostIds: ReadonlySet<string> }
>({
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)
})
@@ -1,15 +1,19 @@
import { e2eConfig } from '@/lib/e2e-config'
/** Facts read on demand carry no revision. */
export type SidebarShadowMainRead<Value> = { revision?: number; values: ReadonlyMap<string, Value> }
/**
* 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<Value> = {
export type SidebarShadowDomain<Value, Main extends SidebarShadowMainRead<Value>> = {
name: string
readWindow: () => ReadonlyMap<string, Value>
/** 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<string, Value> } | null>
/** Given main's read, so a domain can scope the window's side to what main covers. */
readWindow: (main: Main) => ReadonlyMap<string, Value>
/** Null when this host does not publish the domain (an older host). */
readMain: () => Promise<Main | null>
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<Value>(
domain: SidebarShadowDomain<Value>
): SidebarShadowCheck {
export function defineSidebarShadowDomain<
Value,
Main extends SidebarShadowMainRead<Value> = SidebarShadowMainRead<Value>
>(domain: SidebarShadowDomain<Value, Main>): SidebarShadowCheck {
let pending = new Map<string, string>()
const recorded = new Set<string>()
return async (record) => {
@@ -42,7 +47,7 @@ export function defineSidebarShadowDomain<Value>(
if (!main) {
return false
}
const window = domain.readWindow()
const window = domain.readWindow(main)
const next = new Map<string, string>()
for (const key of new Set([...window.keys(), ...main.values.keys()])) {
const windowValue = window.get(key) ?? null
@@ -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'])
})
})
@@ -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<Value>(
state: AppState,
values: ReadonlyMap<string, Value>
): Map<string, Value> {
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<string, Set<'local' | 'server'>>()
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<string, Value>()
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)
}
}
+6
View File
@@ -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)
+72
View File
@@ -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<string, RuntimeEnvironmentStatus>
sshTargetLabels: ReadonlyMap<string, string>
sshConnectionStates: ReadonlyMap<string, SshConnectionState>
hostLabelOverrides: ReadonlyMap<ExecutionHostId, string>
}
/** 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
}
]
})
}
+1
View File
@@ -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,
@@ -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<typeof PairedServerHostFactsSchema>
export type PairedServerWorkspaceRow = z.infer<typeof PairedServerWorkspaceRowSchema>
export type PairedServerRowsSnapshot = z.infer<typeof PairedServerRowsSnapshotSchema>
// 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
}
}
@@ -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()
}
+126
View File
@@ -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<string> =>
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()
}
})