Files
orca/tests/e2e/plugin-workspace-context-paired-server.spec.ts
Pr1pandNeil 3585a38b72 fix(plugins): resolve workspace context for remote active workspaces (#38671)
* fix(plugins): resolve the active workspace context on remote hosts

* fix(plugins): scope the remote workspace context to its host

* docs(plugins): document the remote context resolver

* fix(plugins): keep remote context ids raw and never fall back for local rows

The stored-row fallback returned host-qualified ids (ssh:host|repo::path), which no
terminal selector or agent-status row matches, so agents.list stayed empty for the
remote workspace. It also answered for a removed local worktree, turning a stale
focus that used to read null into a context for a workspace that no longer exists.

* fix(plugins): resolve a focused paired-server workspace from the desktop's server rows

A paired orcad server's workspaces live in that server's store, so neither the local
catalog nor the desktop's stored rows can name one and workspace.readContext,
workspace.changed and agents.list saw no context. The runtime now asks the relay host
catalog, which answers from the worktree.ps rows the desktop already caches. Only a
miss on a reachable server fetches, bounded at 1.5 s and at most once per 10 s per
missing workspace; an unreachable or hung server answers null.

* fix(plugins): keep a stale local focus stamp resolving as before, and cover #38653 end to end

A focus stamped 'local' whose repo row now routes elsewhere resolved through the
unscoped selector before #38653; the host-scoped lookup turned it into null. A
local stamp now retries unscoped, so local and unstamped focus answers exactly
what main did. Remote stamps keep the scoped lookup, so a paired-server focus
never borrows a local twin's context.

Tests: real-git parity of local, folder and SSH contexts against the pre-change
selector answer for every session shape the renderer writes; panel polling
against a hung or failing server (one fetch, one bounded wait per 10 s window);
an e2e that pairs a desktop with a headless Orca server, clicks a local and a
server row, reads workspace.readContext from a plugin panel, and keeps polling
fast after the server goes away.

---------

Co-authored-by: Neil <neil@stably.ai>
2026-11-07 03:04:32 -08:00

233 lines
9.2 KiB
TypeScript

/**
* #38653: a panel's `workspace.readContext` names the focused workspace when it lives on a paired
* Orca server, and a focused local workspace's context is unchanged by that remote fallback.
*/
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Locator, Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host'
import { launchPairedElectronClient } from './helpers/paired-electron-client'
import { enableDevPlugins } from './helpers/plugin-smoke'
import { pluginGuest, readInGuest, waitForGuestReady } from './helpers/plugin-frame-guest'
import { worktreeRowSurface } from './worktree-row-locators'
// What a plugin sees: labels and terminals, never the raw id or path.
type Context = { branch: string; displayName: string; terminals: { id: string }[] } | null
const SERVER_LABEL = 'Server checkout'
const PANEL = `<!doctype html><html><body><script>
window.__ask = () => {
const started = performance.now()
return orca.host.call('workspace.readContext').then((value) => {
window.__answer = { value, ms: performance.now() - started }
}, (error) => {
window.__answer = { value: null, ms: performance.now() - started, error: String(error) }
})
}
</script></body></html>`
async function readContext(
guest: Locator
): Promise<{ value: Context; ms: number; error?: string }> {
await readInGuest(guest, 'window.__answer = null')
await readInGuest(guest, 'window.__ask()')
let answer: { value: Context; ms: number; error?: string } | null = null
await expect
.poll(async () => (answer = await readInGuest(guest, 'window.__answer ?? null')), {
timeout: 15_000
})
.not.toBeNull()
if (answer!.error) {
throw new Error(`workspace.readContext failed: ${answer!.error}`)
}
return answer!
}
function focus(page: Page) {
return page.evaluate(() => {
const state = window.__store!.getState()
return {
activeWorktreeId: state.activeWorktreeId,
activeWorkspaceKey: state.activeWorkspaceKey,
activeWorkspaceExecutionHostId: state.activeWorkspaceExecutionHostId
}
})
}
test('a panel reads the focused paired-server workspace, and a local one as before', async ({
testRepoPath
}, testInfo) => {
// Two Electron launches (server and paired desktop), a sync, and ~16 s of offline panel polling.
test.setTimeout(300_000)
const host = await launchHeadlessPairedRuntimeHost()
let hostRunning = true
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-38653-'))
const pluginRoot = join(tempRoot, 'context-probe')
try {
const added = await host.client.call<{ repo: { id: string } }>('repo.add', {
path: testRepoPath,
kind: 'git'
})
const serverRepoId = added.result.repo.id
const desktop = await launchPairedElectronClient(host.offer, testInfo, 'context host', {
extraEnv: { ORCA_PLUGIN_ISOLATED_FRAMES: '1' }
})
try {
const page = desktop.page
const hostId = `runtime:${desktop.environmentId}`
// The desktop's own checkout of the same repo, beside the server's rows.
const localId = await page.evaluate(async (repoPath) => {
const store = window.__store!
await store.getState().setActiveRuntimeEnvironmentPreference(null)
const repo = await window.api.repos.add({ path: repoPath, displayName: 'Desktop copy' })
if ('error' in repo) {
throw new Error(repo.error)
}
await store.getState().fetchReposForAllHosts()
await store.getState().fetchWorktrees(repo.repo.id, { forceLocalOwner: true })
store.getState().setVisibleWorkspaceHostIds(null)
const worktree = store
.getState()
.worktreesByRepo[repo.repo.id]?.find((row) => row.isMainWorktree)
if (!worktree) {
throw new Error('Local checkout missing')
}
return worktree.id
}, testRepoPath)
const findServerId = (): Promise<string | null> =>
page.evaluate(
({ repoId, hostId }) =>
window.__store
?.getState()
.allWorktrees()
.find((worktree) => worktree.repoId === repoId && worktree.hostId === hostId)?.id ??
null,
{ repoId: serverRepoId, hostId }
)
await expect
.poll(findServerId, {
timeout: 60_000,
message: "The desktop never listed the server's row"
})
.not.toBeNull()
const serverId = (await findServerId())!
expect(serverId).not.toBe(localId)
// A label only the server's copy carries, so the panel's answer names which one it read.
await host.client.call('worktree.set', {
worktree: `id:${serverId}`,
displayName: SERVER_LABEL
})
await mkdir(pluginRoot, { recursive: true })
await writeFile(join(pluginRoot, 'panel.html'), PANEL)
await writeFile(
join(pluginRoot, 'orca-plugin.json'),
JSON.stringify({
manifestVersion: 2,
execution: 'sandboxed-ui',
id: 'context-probe',
name: 'Context Probe',
version: '1.0.0',
engines: { orca: '>=1.4.246' },
pluginApi: 1,
contributes: { panels: [{ id: 'probe', title: 'Context Probe', entry: 'panel.html' }] },
capabilities: [{ kind: 'workspace:read' }]
})
)
await enableDevPlugins(page, [pluginRoot])
await page.evaluate(async () => {
const store = window.__store?.getState()
if (store && !store.rightSidebarOpen) {
store.toggleRightSidebar()
}
await window.api.plugins.refresh()
})
// Local focus through the real sidebar click.
await worktreeRowSurface(page, localId).click()
await expect
.poll(() => focus(page))
.toEqual({
activeWorktreeId: localId,
activeWorkspaceKey: `worktree:${localId}`,
activeWorkspaceExecutionHostId: 'local'
})
await page.getByRole('button', { name: 'Context Probe' }).click({ timeout: 15_000 })
const guest = pluginGuest(page, 'Context Probe')
await waitForGuestReady(guest)
let local: Context = null
await expect
.poll(async () => (local = (await readContext(guest)).value)?.terminals.length ?? 0, {
timeout: 30_000
})
.toBeGreaterThan(0)
expect(local!.branch).not.toBe('')
expect(local!.displayName).not.toBe(SERVER_LABEL)
// The server's workspace, same path on another host, through the same click.
await worktreeRowSurface(page, serverId).click()
await expect
.poll(() => focus(page))
.toEqual({
activeWorktreeId: serverId,
activeWorkspaceKey: `worktree:${serverId}`,
activeWorkspaceExecutionHostId: hostId
})
let remote: Context = null
await expect
.poll(async () => (remote = (await readContext(guest)).value)?.displayName ?? null, {
timeout: 30_000,
message: 'readContext never named the focused server workspace'
})
.toBe(SERVER_LABEL)
expect(remote!.branch).toBe(local!.branch)
// The server's terminals, never the desktop checkout's.
const localTerminalIds = new Set(local!.terminals.map(({ id }) => id))
expect(remote!.terminals.filter(({ id }) => localTerminalIds.has(id))).toEqual([])
// Panel polling must stay off the 1.5 s fetch bound once the desktop holds the server's rows.
const polls: number[] = []
for (let poll = 0; poll < 5; poll += 1) {
const answer = await readContext(guest)
expect(answer.value?.displayName).toBe(SERVER_LABEL)
polls.push(answer.ms)
}
testInfo.annotations.push({ type: 'remote readContext ms', description: polls.join(',') })
expect(Math.max(...polls)).toBeLessThan(1_000)
// The server goes away: polls keep answering promptly, from the rows the desktop holds.
await host.dispose()
hostRunning = false
const offline: number[] = []
// ast-grep-ignore: test-no-polling-loop -- replays a panel's 2 s poll to time each answer; nothing is awaited
for (let poll = 0; poll < 8; poll += 1) {
const answer = await readContext(guest)
expect(answer.value?.displayName).toBe(SERVER_LABEL)
offline.push(answer.ms)
// ast-grep-ignore: test-no-real-time-wait -- the claim spans the real 10 s refetch window of a panel's 2 s poll
await page.waitForTimeout(2_000)
}
testInfo.annotations.push({ type: 'offline readContext ms', description: offline.join(',') })
// At most the 1.5 s fetch bound, and never on consecutive polls.
expect(Math.max(...offline)).toBeLessThan(1_800)
expect(offline.filter((ms) => ms > 1_000).length).toBeLessThanOrEqual(2)
// Back to the local checkout: the same context as before the remote focus.
await worktreeRowSurface(page, localId).click()
await expect
.poll(async () => (await readContext(guest)).value, { timeout: 30_000 })
.toEqual(local)
} finally {
await desktop.dispose()
}
} finally {
if (hostRunning) {
await host.dispose()
}
await rm(tempRoot, { recursive: true, force: true })
}
})