mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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'
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user