feat(sidebar): put main's sidebar inputs behind the ORCA_SIDEBAR_INPUTS dark build switch (#30050)

This commit is contained in:
Jinwoo Hong
2026-10-17 04:06:43 -07:00
committed by GitHub
parent 90c1c0ac50
commit 6fcc183ab9
31 changed files with 945 additions and 165 deletions
+70
View File
@@ -0,0 +1,70 @@
const { existsSync } = require('node:fs')
const { join } = require('node:path')
// Dark build switches: a build compiles a switch's path in only when its variable is 'main'. Such a
// main build leaves the switch's marker in out/main, and packaging refuses it; packaging and the
// orcad template also refuse the variable itself.
const DARK_BUILD_SWITCHES = [
// src/shared/layout-owner-switch.ts
{ variable: 'ORCA_LAYOUT_OWNER', off: 'window' },
// src/shared/sidebar-inputs-switch.ts
{ variable: 'ORCA_SIDEBAR_INPUTS', off: 'window' }
]
function markerFileName(darkSwitch) {
return `${darkSwitch.variable}_MAIN`
}
function turnedOn(env) {
return DARK_BUILD_SWITCHES.filter((darkSwitch) => env[darkSwitch.variable] === 'main')
}
/** Every switch's define: 'main' only when asked, so other builds fold the branches away. */
function darkBuildDefines(env = process.env) {
const on = turnedOn(env)
return Object.fromEntries(
DARK_BUILD_SWITCHES.map((darkSwitch) => [
darkSwitch.variable,
JSON.stringify(on.includes(darkSwitch) ? 'main' : darkSwitch.off)
])
)
}
/** Only the switches asked for: an undeclared switch already reads as off. */
function turnedOnDarkBuildDefines(env = process.env) {
return Object.fromEntries(
turnedOn(env).map((darkSwitch) => [darkSwitch.variable, JSON.stringify('main')])
)
}
/** The marker each switched-on build leaves in out/main. */
function darkBuildMarkers(env = process.env) {
return turnedOn(env).map((darkSwitch) => ({
fileName: markerFileName(darkSwitch),
text: `Built with ${darkSwitch.variable}=main, for dark testing only.\n`
}))
}
function assertNotDarkBuild(outputDirs, env = process.env) {
const [asked] = turnedOn(env)
if (asked) {
throw new Error(
`${asked.variable}=main is for dark test builds; unset it to package a release.`
)
}
for (const darkSwitch of DARK_BUILD_SWITCHES) {
const marked = outputDirs.find((dir) => existsSync(join(dir, markerFileName(darkSwitch))))
if (marked) {
throw new Error(
`${marked} holds an ${darkSwitch.variable}=main build; rebuild it without that variable first.`
)
}
}
}
module.exports = {
assertNotDarkBuild,
darkBuildDefines,
darkBuildMarkers,
turnedOnDarkBuildDefines
}
+2 -2
View File
@@ -54,7 +54,7 @@ const {
orcadTemplateMacSignIgnore
} = require('./scripts/packaged-orcad-template.cjs')
const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs')
const { assertNotLayoutOwnerBuild } = require('./layout-owner-build.cjs')
const { assertNotDarkBuild } = require('./dark-build-switches.cjs')
const { verifyStaticAppImagePackage } = require('./scripts/static-appimage-package-contract.cjs')
const {
appImageNodeRuntimeFpmArgs,
@@ -399,7 +399,7 @@ module.exports = {
mobileWebBundleDir = MOBILE_WEB_BUNDLE_DIR,
ensureWaylandClipboard = ensureBundledWaylandClipboard
) => {
assertNotLayoutOwnerBuild([join(__dirname, '..', 'out', 'main')])
assertNotDarkBuild([join(__dirname, '..', 'out', 'main')])
assertPackagedNativeVariantsInstalled(context.electronPlatformName, context.arch)
assertBundledRipgrepInstalled()
assertProcessHostOutputBuilt()
-39
View File
@@ -1,39 +0,0 @@
const { existsSync } = require('node:fs')
const { join } = require('node:path')
// The dark layout-owner switch (src/shared/layout-owner-switch.ts): a build compiles it in only
// when ORCA_LAYOUT_OWNER=main asks. Such a main build leaves this marker in out/main, and packaging
// refuses it; packaging and the orcad template also refuse the variable itself.
const LAYOUT_OWNER_MARKER_FILENAME = 'ORCA_LAYOUT_OWNER_MAIN'
const LAYOUT_OWNER_MARKER_TEXT = 'Built with ORCA_LAYOUT_OWNER=main, for dark testing only.\n'
function isLayoutOwnerBuild(env = process.env) {
return env.ORCA_LAYOUT_OWNER === 'main'
}
/** The `ORCA_LAYOUT_OWNER` define: 'main' only when asked, so other builds fold the branches away. */
function layoutOwnerDefine(env = process.env) {
return JSON.stringify(isLayoutOwnerBuild(env) ? 'main' : 'window')
}
function assertNotLayoutOwnerBuild(outputDirs, env = process.env) {
if (isLayoutOwnerBuild(env)) {
throw new Error(
'ORCA_LAYOUT_OWNER=main is for dark test builds; unset it to package a release.'
)
}
const marked = outputDirs.find((dir) => existsSync(join(dir, LAYOUT_OWNER_MARKER_FILENAME)))
if (marked) {
throw new Error(
`${marked} holds an ORCA_LAYOUT_OWNER=main build; rebuild it without that variable first.`
)
}
}
module.exports = {
LAYOUT_OWNER_MARKER_FILENAME,
LAYOUT_OWNER_MARKER_TEXT,
assertNotLayoutOwnerBuild,
isLayoutOwnerBuild,
layoutOwnerDefine
}
+2 -2
View File
@@ -38,7 +38,7 @@ import {
import { runProcessSync } from '@orca/process-host'
import { verifyPackagedOrcadTemplate } from './verify-packaged-orcad-template.cjs'
import { readOrcadMobileWebAssetFilenames } from './orcad-mobile-web-bundle.mjs'
import { assertNotLayoutOwnerBuild } from '../layout-owner-build.cjs'
import { assertNotDarkBuild } from '../dark-build-switches.cjs'
const root = resolve(import.meta.dirname, '../..')
const outputDir = join(root, 'out', 'orcad-template')
@@ -181,7 +181,7 @@ function sameBytes(left, right) {
async function main() {
// The template is what releases ship as the server; a dark build must never become one.
assertNotLayoutOwnerBuild([])
assertNotDarkBuild([])
const templateTargets = requestedTemplateTargets()
rmSync(buildDir, { recursive: true, force: true })
const packages = Object.fromEntries(
@@ -0,0 +1,51 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const { assertNotDarkBuild, darkBuildDefines, darkBuildMarkers, turnedOnDarkBuildDefines } =
createRequire(import.meta.url)('../dark-build-switches.cjs')
const scratch = []
afterEach(async () => {
await Promise.all(scratch.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
})
describe('dark build switches', () => {
it.each(['ORCA_LAYOUT_OWNER', 'ORCA_SIDEBAR_INPUTS'])(
'compiles the dark path in only when %s=main asks',
(variable) => {
expect(darkBuildDefines({ [variable]: 'main' })[variable]).toBe('"main"')
expect(darkBuildDefines({ [variable]: 'window' })[variable]).toBe('"window"')
expect(darkBuildDefines({})[variable]).toBe('"window"')
expect(turnedOnDarkBuildDefines({ [variable]: 'main' })).toEqual({ [variable]: '"main"' })
expect(turnedOnDarkBuildDefines({})).toEqual({})
}
)
it('keeps the layout-owner marker name packaging already refuses', () => {
expect(darkBuildMarkers({ ORCA_LAYOUT_OWNER: 'main' })).toEqual([
{
fileName: 'ORCA_LAYOUT_OWNER_MAIN',
text: 'Built with ORCA_LAYOUT_OWNER=main, for dark testing only.\n'
}
])
expect(darkBuildMarkers({})).toEqual([])
})
it.each(['ORCA_LAYOUT_OWNER', 'ORCA_SIDEBAR_INPUTS'])(
'refuses %s at packaging time, and any output its dark build marked',
async (variable) => {
const clean = await mkdtemp(join(tmpdir(), 'orca-dark-build-clean-'))
const dark = await mkdtemp(join(tmpdir(), 'orca-dark-build-dark-'))
scratch.push(clean, dark)
const [marker] = darkBuildMarkers({ [variable]: 'main' })
await writeFile(join(dark, marker.fileName), marker.text)
expect(() => assertNotDarkBuild([clean], {})).not.toThrow()
expect(() => assertNotDarkBuild([clean], { [variable]: 'main' })).toThrow(`${variable}=main`)
expect(() => assertNotDarkBuild([clean, dark], {})).toThrow(dark)
}
)
})
@@ -97,8 +97,8 @@ function loadRegistryAndSchemas(modules) {
outfile,
logLevel: 'error',
packages: 'external',
// Why: the catalog types every method a build can register, layout-owner-only ones included.
define: { ORCA_LAYOUT_OWNER: '"main"' }
// Why: the catalog types every method a build can register, every dark switch's included.
define: { ORCA_LAYOUT_OWNER: '"main"', ORCA_SIDEBAR_INPUTS: '"main"' }
})
const loaded = createRequire(import.meta.url)(outfile)
return { methods: loaded.ALL_RPC_METHODS, schemaModules: loaded.SCHEMA_MODULES }
@@ -85,8 +85,8 @@ it('activates the compiler and workspace-policy parser and preserves import grap
)
}
cpSync(
new URL('../layout-owner-build.cjs', import.meta.url),
join(root, 'config', 'layout-owner-build.cjs')
new URL('../dark-build-switches.cjs', import.meta.url),
join(root, 'config', 'dark-build-switches.cjs')
)
mkdirSync(join(root, 'src', 'shared'), { recursive: true })
cpSync(
@@ -1,34 +0,0 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const { LAYOUT_OWNER_MARKER_FILENAME, assertNotLayoutOwnerBuild, layoutOwnerDefine } =
createRequire(import.meta.url)('../layout-owner-build.cjs')
const scratch = []
afterEach(async () => {
await Promise.all(scratch.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
})
describe('layout-owner build switch', () => {
it('compiles the dark path in only when ORCA_LAYOUT_OWNER=main asks', () => {
expect(layoutOwnerDefine({ ORCA_LAYOUT_OWNER: 'main' })).toBe('"main"')
expect(layoutOwnerDefine({ ORCA_LAYOUT_OWNER: 'window' })).toBe('"window"')
expect(layoutOwnerDefine({})).toBe('"window"')
})
it('refuses the switch at packaging time, and any output a dark build marked', async () => {
const clean = await mkdtemp(join(tmpdir(), 'orca-layout-owner-clean-'))
const dark = await mkdtemp(join(tmpdir(), 'orca-layout-owner-dark-'))
scratch.push(clean, dark)
await writeFile(join(dark, LAYOUT_OWNER_MARKER_FILENAME), 'dark\n')
expect(() => assertNotLayoutOwnerBuild([clean], {})).not.toThrow()
expect(() => assertNotLayoutOwnerBuild([clean], { ORCA_LAYOUT_OWNER: 'main' })).toThrow(
'ORCA_LAYOUT_OWNER=main'
)
expect(() => assertNotLayoutOwnerBuild([clean, dark], {})).toThrow(dark)
})
})
+2 -4
View File
@@ -6,9 +6,7 @@ import { basename, dirname, join } from 'node:path'
import { ORCAD_SERVER_ENTRY_FILENAME } from '../../src/shared/orcad-artifacts.ts'
const root = join(import.meta.dirname, '..', '..')
const { isLayoutOwnerBuild, layoutOwnerDefine } = createRequire(import.meta.url)(
'../layout-owner-build.cjs'
)
const { turnedOnDarkBuildDefines } = createRequire(import.meta.url)('../dark-build-switches.cjs')
export const ORCAD_ENTRY_POINT = 'src/main/orcad/main.ts'
export const ORCAD_LAUNCHER_ENTRY_POINT = 'src/main/orcad/launcher.ts'
@@ -40,7 +38,7 @@ export const ORCAD_COMMONJS_MODULE_OPTIONS = {
define: {
'import.meta.url': '__orcadModuleUrl',
// Why only when on: an undeclared switch already reads as off, so other builds keep their bytes.
...(isLayoutOwnerBuild() ? { ORCA_LAYOUT_OWNER: layoutOwnerDefine() } : {})
...turnedOnDarkBuildDefines()
},
logOverride: { 'empty-import-meta': 'error' }
}
+14 -18
View File
@@ -55,24 +55,20 @@ const ORCA_DIAGNOSTICS_TOKEN_URL_LITERAL =
? JSON.stringify(orcaDiagnosticsTokenUrl)
: 'null'
const layoutOwnerBuild: {
LAYOUT_OWNER_MARKER_FILENAME: string
LAYOUT_OWNER_MARKER_TEXT: string
isLayoutOwnerBuild: () => boolean
layoutOwnerDefine: () => string
} = createRequire(import.meta.url)('./config/layout-owner-build.cjs')
const ORCA_LAYOUT_OWNER_LITERAL = layoutOwnerBuild.layoutOwnerDefine()
const darkBuildSwitches: {
darkBuildDefines: () => Record<string, string>
darkBuildMarkers: () => { fileName: string; text: string }[]
} = createRequire(import.meta.url)('./config/dark-build-switches.cjs')
const DARK_BUILD_DEFINES = darkBuildSwitches.darkBuildDefines()
/** Marks a dark layout-owner main build so packaging refuses it. */
function createLayoutOwnerMarkerPlugin(): Plugin {
/** Marks a dark switched-on main build so packaging refuses it. */
function createDarkBuildMarkerPlugin(): Plugin {
return {
name: 'orca-layout-owner-marker',
name: 'orca-dark-build-marker',
generateBundle() {
this.emitFile({
type: 'asset',
fileName: layoutOwnerBuild.LAYOUT_OWNER_MARKER_FILENAME,
source: layoutOwnerBuild.LAYOUT_OWNER_MARKER_TEXT
})
for (const marker of darkBuildSwitches.darkBuildMarkers()) {
this.emitFile({ type: 'asset', fileName: marker.fileName, source: marker.text })
}
}
}
}
@@ -261,7 +257,7 @@ export const electronViteConfig: UserConfig = {
createMainBootstrapPlugin(),
createPlainNodeEntryGuardPlugin(),
createProcessHostDevRebuildPlugin(),
...(layoutOwnerBuild.isLayoutOwnerBuild() ? [createLayoutOwnerMarkerPlugin()] : [])
createDarkBuildMarkerPlugin()
]
}
},
@@ -272,7 +268,7 @@ export const electronViteConfig: UserConfig = {
ORCA_POSTHOG_WRITE_KEY: ORCA_POSTHOG_WRITE_KEY_LITERAL,
ORCA_DIAGNOSTICS_TOKEN_URL: ORCA_DIAGNOSTICS_TOKEN_URL_LITERAL,
ORCA_BUILD_COMMIT: ORCA_BUILD_COMMIT_LITERAL,
ORCA_LAYOUT_OWNER: ORCA_LAYOUT_OWNER_LITERAL
...DARK_BUILD_DEFINES
},
// Why: @xterm/headless declares "exports": null in package.json, which
// prevents Vite's default resolver from finding the CJS entry. Point
@@ -298,7 +294,7 @@ export const electronViteConfig: UserConfig = {
cacheDir: resolve('.cache/vite/renderer'),
define: {
ORCA_BUILD_COMMIT: ORCA_BUILD_COMMIT_LITERAL,
ORCA_LAYOUT_OWNER: ORCA_LAYOUT_OWNER_LITERAL
...DARK_BUILD_DEFINES
},
resolve: {
alias: {
+5 -1
View File
@@ -294,7 +294,11 @@ async function startOrcadRuntime(
agentHookServer,
() => runtime
)
registerCleanup(runtime.workspaceActivityFacts.attachAgentStatusStore(agentHookServer))
if (runtime.sidebarFacts) {
registerCleanup(
runtime.sidebarFacts.workspaceActivityFacts.attachAgentStatusStore(agentHookServer)
)
}
// Why the headless entry point rather than registerPtyHandlers directly: this is the
// same call `--serve` makes, and it threads the store through. Without the store the
+1 -1
View File
@@ -130,7 +130,7 @@ vi.mock('../server/serve-readiness', () => ({
}))
vi.mock('../runtime/orca-runtime', () => ({
OrcaRuntimeService: class {
readonly workspaceActivityFacts = { attachAgentStatusStore: () => () => {} }
readonly sidebarFacts = null
getRuntimeId() {
return 'headless-runtime'
}
@@ -15,13 +15,20 @@ 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 {
SERVER_FETCH_TIMEOUT_MS,
type PairedServerRowsProducer
} from '../sidebar-inputs/paired-server-rows-producer'
import { SERVER_FETCH_TIMEOUT_MS } from './server-worktree-fetch'
import type { MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts'
import { describeRelayHosts, relayHostFence } from './mobile-relay-host-descriptions'
/** Where the catalog reads each server's rows: main's live set, or a fetch per phone request. */
export type ServerWorktreeRows = {
/** Catches up with the configured servers before a read. */
sync: () => void
/** Fetches now, or joins the fetch in flight. */
refresh: (environmentId: string) => Promise<void>
/** The server's rows as last fetched under this pairing; null when none were. */
rows: (environmentId: string, fence: string) => readonly MobileRelayServerWorktreeRow[] | null
}
/**
* The configured servers the desktop shows, for the phone: health from the desktop's own status
* (the sidebar's mapping), and each server's workspace list as the desktop itself last fetched it,
@@ -32,8 +39,8 @@ export class MobileRelayHostCatalog {
constructor(
private readonly options: {
hosts: MobileDesktopRelayHosts
/** The desktop's one live set of server rows; this catalog keeps no copy. */
serverRows: PairedServerRowsProducer
/** This catalog keeps no copy of the rows. */
serverRows: ServerWorktreeRows
hostLabelOverrides: () => ReadonlyMap<ExecutionHostId, string>
/** The desktop's saved session for a host, as its sidebar last wrote it. */
getWorkspaceSession: (hostId: ExecutionHostId) => WorkspaceSessionState | null
@@ -0,0 +1,518 @@
// The phone's server catalog with the sidebar-inputs switch off: the suite as it stood before main
// kept live server rows, run unchanged over the fetch-per-request source.
import { describe, expect, it, vi } from 'vitest'
import { DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY } from '../../../shared/delegated-mobile-device-contract'
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 { RuntimeStatus } from '../../../shared/runtime-types'
import type { SshConnectionState } from '../../../shared/ssh-types'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
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 { OnDemandServerWorktreeRows } from './on-demand-server-worktree-rows'
function answer(capable: boolean): RuntimeStatus {
return {
runtimeId: 'runtime-a',
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
capabilities: capable ? [DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY] : []
}
}
function snapshot(
environmentId: string,
contact: 'live' | 'unreachable' | 'never-answered',
capable = true
): RuntimeHostStatusSnapshot {
return {
environmentId,
pairingRevision: 1,
sequence: 1,
checkedAt: 1,
status: contact === 'never-answered' ? null : answer(capable),
verification: contact === 'live' ? 'verified' : 'unavailable',
transport: contact === 'live' ? 'ready' : 'disconnected'
}
}
function psReply(rows: Record<string, unknown>[]): RuntimeRpcResponse<unknown> {
return {
id: 'ps',
ok: true,
result: { worktrees: rows, totalCount: rows.length, truncated: false },
_meta: { runtimeId: 'runtime-a' }
}
}
function fakeHosts(
environments: {
id: string
name: string
snapshot: RuntimeHostStatusSnapshot
orcadDeployment?: { sshTargetId: string }
runtimeId?: string | null
pairedDeviceId?: string
}[]
) {
const state = {
environments,
pairingRevision: 1,
retire: (_environmentId: string) => {},
sshTargetLabels: new Map<string, string>(),
sshConnectionStates: new Map<string, SshConnectionState>()
}
const call = vi.fn<MobileDesktopRelayHosts['call']>()
const hosts: MobileDesktopRelayHosts = {
list: () => ({
environments: state.environments.map(
({ id, name, orcadDeployment, runtimeId, pairedDeviceId }) => ({
id,
name,
orcadDeployment,
pairingRevision: state.pairingRevision,
runtimeId: runtimeId === undefined ? 'runtime-a' : runtimeId,
pairedDeviceId
})
),
sshTargetLabels: state.sshTargetLabels,
sshConnectionStates: state.sshConnectionStates,
statusByEnvironmentId: new Map(
state.environments.map((entry) => [
entry.id,
runtimeEnvironmentStatusFromSnapshot(entry.snapshot)
])
)
}),
resolve: async (environmentId) => ({
environmentId,
fence: 'unused',
pairing: { v: 2, endpoint: 'ws://server', deviceToken: 'desktop', publicKeyB64: 'k' }
}),
call,
hostsBrowserPage: () => false,
onEnvironmentRetired: (listener) => {
state.retire = listener
return () => {}
},
onStatusChanged: () => () => {},
watchChanges: () => ({ close: () => {}, current: () => true })
}
return { hosts, call, state }
}
function catalog(
hosts: MobileDesktopRelayHosts,
labels = new Map(),
desktop: {
sessions?: Record<string, Partial<WorkspaceSessionState>>
} = {}
) {
return {
catalog: new MobileRelayHostCatalog({
hosts,
serverRows: new OnDemandServerWorktreeRows(hosts),
hostLabelOverrides: () => labels,
getWorkspaceSession: (hostId) => {
const session = desktop.sessions?.[hostId]
return session ? { ...getDefaultWorkspaceSession(), ...session } : null
}
})
}
}
describe('mobile relay host catalog, fetching on demand', () => {
it("lists every configured server with the desktop's health and whether it can relay", async () => {
const { hosts, call } = fakeHosts([
{ id: 'ready', name: 'Box', snapshot: snapshot('ready', 'live') },
{ id: 'old', name: 'ThinkPad', snapshot: snapshot('old', 'live', false) },
{ id: 'down', name: 'VM', snapshot: snapshot('down', 'unreachable') },
{ id: 'old-down', name: 'Pi', snapshot: snapshot('old-down', 'unreachable', false) },
{ id: 'new', name: 'Fresh', snapshot: snapshot('new', 'never-answered') }
])
call.mockResolvedValue(psReply([]))
const { catalog: hostCatalog } = catalog(hosts, new Map([['runtime:ready', 'Build box']]))
expect(hostCatalog.list().hosts).toEqual([
{ hostId: 'runtime:ready', label: 'Build box', health: 'available', relay: 'ready' },
{ hostId: 'runtime:old', label: 'ThinkPad', health: 'available', relay: 'update-needed' },
{ hostId: 'runtime:down', label: 'VM', health: 'connecting', relay: 'unavailable' },
// A build's capabilities outlive contact, so an old server stays update-needed offline.
{ hostId: 'runtime:old-down', label: 'Pi', health: 'connecting', relay: 'update-needed' },
{ hostId: 'runtime:new', label: 'Fresh', health: 'connecting', relay: 'unavailable' }
])
// Listing reads what the desktop already knows; it never contacts a server.
await new Promise((resolve) => setTimeout(resolve, 0))
expect(call).not.toHaveBeenCalled()
})
it("lists a managed server once, under its SSH host's name and rename, as the sidebar does", async () => {
const { hosts, state } = fakeHosts([
{
id: 'managed',
name: 'orcad on devbox',
snapshot: snapshot('managed', 'live'),
orcadDeployment: { sshTargetId: 'devbox' }
}
])
state.sshTargetLabels.set('devbox', 'Dev Box')
expect(catalog(hosts).catalog.list().hosts).toEqual([
{ hostId: 'runtime:managed', label: 'Dev Box', health: 'available', relay: 'ready' }
])
const renamed = catalog(hosts, new Map([['ssh:devbox', 'Build box']])).catalog
expect(renamed.list().hosts.map((host) => host.label)).toEqual(['Build box'])
})
it('does not list a managed server while its SSH host is reached over the SSH relay', () => {
const { hosts, state } = fakeHosts([
{
id: 'managed',
name: 'orcad on devbox',
snapshot: snapshot('managed', 'live'),
orcadDeployment: { sshTargetId: 'devbox' }
},
{ id: 'other', name: 'Box', snapshot: snapshot('other', 'live') }
])
state.sshTargetLabels.set('devbox', 'Dev Box')
state.sshConnectionStates.set('devbox', {
targetId: 'devbox',
status: 'connected',
error: null,
reconnectAttempt: 0,
managedServer: { kind: 'relay', reason: 'relay_terminals_live' }
})
// Its workspaces are the SSH host's, which the desktop already lists to the phone itself.
expect(
catalog(hosts)
.catalog.list()
.hosts.map((host) => host.hostId)
).toEqual(['runtime:other'])
})
it('encodes an environment id into the host id the desktop uses', () => {
const { hosts } = fakeHosts([
{ id: 'a b', name: 'Spaced', snapshot: snapshot('a b', 'unreachable') }
])
expect(catalog(hosts).catalog.list().hosts[0]?.hostId).toBe('runtime:a%20b')
})
it("serves a server's rows fetched as the desktop, re-stamped with the server's host id", async () => {
const { hosts, call } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }
])
call.mockResolvedValue(
psReply([
{ worktreeId: 'w-local', hostId: 'local', status: 'working' },
{ worktreeId: 'w-ssh', hostId: 'ssh:devbox', status: 'inactive' },
{ worktreeId: 'w-legacy', status: 'done' }
])
)
const { catalog: hostCatalog } = catalog(hosts)
await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({
worktrees: [
{ worktreeId: 'w-local', hostId: 'runtime:env', status: 'working' },
{ worktreeId: 'w-ssh', hostId: 'runtime:env', status: 'inactive' },
{ worktreeId: 'w-legacy', hostId: 'runtime:env', status: 'done' }
]
})
expect(call).toHaveBeenCalledWith(
expect.objectContaining({ environmentId: 'env' }),
'worktree.ps',
// The phone's full-catalog limit: the server's default page would silently truncate.
{ limit: 10_000, supportsWorktreeVisibilitySourceDefaults: true },
{ timeoutMs: 5_000, expected: { pairingRevision: 1, runtimeId: 'runtime-a' } }
)
})
it('keeps the last rows, unchanged, once the server stops answering', async () => {
const { hosts, call, state } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }
])
call.mockResolvedValue(psReply([{ worktreeId: 'w', hostId: 'local', status: 'working' }]))
const { catalog: hostCatalog } = catalog(hosts)
await hostCatalog.worktrees('runtime:env')
state.environments = [{ id: 'env', name: 'Box', snapshot: snapshot('env', 'unreachable') }]
const offline = await hostCatalog.worktrees('runtime:env')
// Never re-fetched while the desktop itself cannot reach it, and never rewritten as idle.
expect(call).toHaveBeenCalledTimes(1)
expect(offline).toEqual({
worktrees: [{ worktreeId: 'w', hostId: 'runtime:env', status: 'working' }]
})
// A failed fetch on a server that looks reachable also serves the last rows.
state.environments = [{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }]
call.mockRejectedValueOnce(new Error('socket closed'))
await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual(offline)
expect(call).toHaveBeenCalledTimes(2)
})
it('omits rows mid-delete, as a phone asking the server itself would never see them', async () => {
const { hosts, call } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }
])
call.mockResolvedValue({
id: 'ps',
ok: true,
result: {
worktrees: [
{ worktreeId: 'kept', hostId: 'local' },
{ worktreeId: 'deleting', hostId: 'local', removing: true }
],
totalCount: 5,
truncated: true
},
_meta: { runtimeId: 'runtime-a' }
})
await expect(catalog(hosts).catalog.worktrees('runtime:env')).resolves.toEqual({
worktrees: [{ worktreeId: 'kept', hostId: 'runtime:env' }]
})
})
it('lists an update-needed server live, since the desktop itself can still reach it', async () => {
const { hosts, call } = fakeHosts([
{ id: 'old', name: 'ThinkPad', snapshot: snapshot('old', 'live', false) }
])
call.mockResolvedValue(psReply([{ worktreeId: 'w', hostId: 'local' }]))
await expect(catalog(hosts).catalog.worktrees('runtime:old')).resolves.toEqual({
worktrees: [{ worktreeId: 'w', hostId: 'runtime:old' }]
})
})
it('has no rows for a server never fetched, an unknown host, or rows from before a re-pair', async () => {
const { hosts, call, state } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'unreachable') }
])
const { catalog: hostCatalog } = catalog(hosts)
await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({ worktrees: null })
await expect(hostCatalog.worktrees('runtime:other')).resolves.toEqual({ worktrees: null })
await expect(hostCatalog.worktrees('ssh:env')).resolves.toEqual({ worktrees: null })
state.environments = [{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }]
call.mockResolvedValue(psReply([{ worktreeId: 'w', hostId: 'local' }]))
await hostCatalog.worktrees('runtime:env')
state.pairingRevision = 2
state.environments = [{ id: 'env', name: 'Box', snapshot: snapshot('env', 'unreachable') }]
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 () => {
const { hosts, call, state } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }
])
let answerOldFetch: (response: RuntimeRpcResponse<unknown>) => void = () => {}
call.mockReturnValueOnce(new Promise((resolve) => (answerOldFetch = resolve)))
const { catalog: hostCatalog } = catalog(hosts)
const rows = hostCatalog.worktrees('runtime:env')
await vi.waitFor(() => expect(call).toHaveBeenCalledTimes(1))
state.pairingRevision = 2
call.mockResolvedValueOnce(psReply([{ worktreeId: 'new-server-row', hostId: 'local' }]))
// Never joins the old server's fetch, still pending here.
const next = hostCatalog.worktrees('runtime:env')
await vi.waitFor(() => expect(call).toHaveBeenCalledTimes(2))
expect(call).toHaveBeenLastCalledWith(expect.anything(), 'worktree.ps', expect.anything(), {
timeoutMs: 5_000,
expected: { pairingRevision: 2, runtimeId: 'runtime-a' }
})
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 })
})
it("forgets a removed server's rows, even ones its last fetch delivers after removal", async () => {
const { hosts, call, state } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }
])
let answerFetch: (response: RuntimeRpcResponse<unknown>) => void = () => {}
call.mockReturnValue(new Promise((resolve) => (answerFetch = resolve)))
const { catalog: hostCatalog } = catalog(hosts)
const rows = hostCatalog.worktrees('runtime:env')
await vi.waitFor(() => expect(call).toHaveBeenCalledTimes(1))
const removed = state.environments
state.environments = []
state.retire('env')
answerFetch(psReply([{ worktreeId: 'w', hostId: 'local' }]))
await expect(rows).resolves.toEqual({ worktrees: null })
expect(hostCatalog.list().hosts).toEqual([])
// Added back under the same id and identity: the late rows were pruned, not kept.
state.environments = [{ ...removed[0]!, snapshot: snapshot('env', 'unreachable') }]
await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({ worktrees: null })
})
it('keeps the last rows for a server manually disconnected but still configured', async () => {
const { hosts, call, state } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }
])
call.mockResolvedValue(psReply([{ worktreeId: 'w', hostId: 'local' }]))
const { catalog: hostCatalog } = catalog(hosts)
await hostCatalog.worktrees('runtime:env')
// A manual disconnect retires the desktop's transport and its status owner.
state.environments = [
{
id: 'env',
name: 'Box',
snapshot: { ...snapshot('env', 'unreachable'), retired: true, verification: 'blocked' }
}
]
state.retire('env')
await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({
worktrees: [{ worktreeId: 'w', hostId: 'runtime:env' }]
})
expect(call).toHaveBeenCalledTimes(1)
})
it('lists a server whose runtime id the desktop has not recorded yet', async () => {
const { hosts, call } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live'), runtimeId: null }
])
call.mockResolvedValue(psReply([{ worktreeId: 'w', hostId: 'local' }]))
await expect(catalog(hosts).catalog.worktrees('runtime:env')).resolves.toMatchObject({
worktrees: [{ worktreeId: 'w' }]
})
expect(call).toHaveBeenCalledWith(expect.anything(), 'worktree.ps', expect.anything(), {
timeoutMs: 5_000,
expected: { pairingRevision: 1, runtimeId: null }
})
})
it('answers with the last rows when the server never answers the fetch', async () => {
vi.useFakeTimers()
try {
const { hosts, call } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }
])
call.mockResolvedValueOnce(psReply([{ worktreeId: 'w', hostId: 'local' }]))
const { catalog: hostCatalog } = catalog(hosts)
await hostCatalog.worktrees('runtime:env')
call.mockReturnValue(new Promise(() => {}))
let settled = false
const rows = hostCatalog.worktrees('runtime:env').finally(() => (settled = true))
await vi.advanceTimersByTimeAsync(4_999)
expect(settled).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await expect(rows).resolves.toEqual({
worktrees: [{ worktreeId: 'w', hostId: 'runtime:env' }]
})
} finally {
vi.useRealTimers()
}
})
it('shares one in-flight fetch per server between concurrent rows requests', async () => {
const { hosts, call } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }
])
let answerFetch: (response: RuntimeRpcResponse<unknown>) => void = () => {}
call.mockReturnValue(new Promise((resolve) => (answerFetch = resolve)))
const { catalog: hostCatalog } = catalog(hosts)
const first = hostCatalog.worktrees('runtime:env')
const second = hostCatalog.worktrees('runtime:env')
await vi.waitFor(() => expect(call).toHaveBeenCalledTimes(1))
answerFetch(psReply([{ worktreeId: 'w', hostId: 'local' }]))
const rows = { worktrees: [{ worktreeId: 'w', hostId: 'runtime:env' }] }
await expect(Promise.all([first, second])).resolves.toEqual([rows, rows])
expect(call).toHaveBeenCalledTimes(1)
})
})
describe("mobile relay host catalog, fetching on demand: the desktop sidebar's own view of server rows", () => {
// What resolveRpcWorkspaceCreatorProvenance records: a relayed phone records the desktop relaying it.
const creators = [
{ worktreeId: 'mine', creatorProvenance: { kind: 'paired-device', deviceId: 'desktop' } },
{ worktreeId: 'other', creatorProvenance: { kind: 'paired-device', deviceId: 'laptop' } },
{ worktreeId: 'server', creatorProvenance: { kind: 'host' } },
{ worktreeId: 'unknown' }
]
function serverRows(desktop: Parameters<typeof catalog>[2], pairedDeviceId = 'desktop') {
const { hosts, call } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live'), pairedDeviceId }
])
call.mockResolvedValue(psReply(creators))
return catalog(hosts, new Map(), desktop).catalog.worktrees('runtime:env')
}
function marked(rows: Awaited<ReturnType<typeof serverRows>>, field: string): unknown[] {
return (rows.worktrees ?? []).filter((row) => row[field] === true).map((row) => row.worktreeId)
}
it('marks rows someone else created, whatever the desktop filter says', async () => {
const rows = await serverRows({})
expect(marked(rows, 'desktopOtherClient')).toEqual(['other', 'server'])
// Marked, never dropped: deep links and lineage still resolve every row.
expect(rows.worktrees).toHaveLength(creators.length)
})
it('marks nothing on a server whose pairing the desktop cannot name', async () => {
const { hosts, call } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }
])
call.mockResolvedValue(psReply(creators))
const rows = await catalog(hosts, new Map()).catalog.worktrees('runtime:env')
expect(marked(rows, 'desktopOtherClient')).toEqual([])
})
function browserTab(worktreeId: string): BrowserWorkspace {
return {
id: `browser-${worktreeId}`,
worktreeId,
url: 'about:blank',
title: '',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0
}
}
function tab(
worktreeId: string,
contentType: Tab['contentType'],
agent?: Tab['agentSessionAgent']
): Tab {
return {
id: `${contentType}-${worktreeId}`,
entityId: worktreeId,
groupId: 'group',
worktreeId,
contentType,
label: '',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0,
...(agent ? { agentSessionAgent: agent } : {})
}
}
it("counts the desktop's saved browser or chat tabs as the row's sidebar activity", async () => {
const rows = await serverRows({
sessions: {
'runtime:env': {
browserTabsByWorktree: { mine: [browserTab('mine')], other: [] },
unifiedTabs: {
'worktree:server': [tab('server', 'agent-session', 'claude')],
unknown: [tab('unknown', 'terminal')]
}
},
// Another host's session says nothing about this server's rows.
local: { browserTabsByWorktree: { other: [browserTab('other')] } }
}
})
expect(marked(rows, 'hasHostSidebarActivity')).toEqual(['mine', 'server'])
})
})
@@ -0,0 +1,87 @@
import type { MobileRelayServerWorktreeRow } from '../../../shared/mobile-relay-hosts-contract'
import {
SERVER_FETCH_TIMEOUT_MS,
SERVER_WORKTREE_LIMIT,
WorktreePsReplySchema,
stampServerWorktreeRows
} from './server-worktree-fetch'
import type { MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts'
import type { ServerWorktreeRows } from './mobile-relay-host-catalog'
import {
describeRelayHosts,
relayHostFence,
type DescribedRelayHost
} from './mobile-relay-host-descriptions'
type CachedServerWorktrees = {
fence: string
worktrees: MobileRelayServerWorktreeRow[]
}
/** Each server's rows as the desktop last fetched them for a phone request; nothing runs between. */
export class OnDemandServerWorktreeRows implements ServerWorktreeRows {
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 hosts: MobileDesktopRelayHosts) {}
// Why lazily, not on retirement: a disconnected server keeps its rows; only a removed one loses them.
sync(): void {
const configured = new Set(this.hosts.list().environments.map((environment) => environment.id))
for (const environmentId of this.cached.keys()) {
if (!configured.has(environmentId)) {
this.cached.delete(environmentId)
}
}
}
refresh(environmentId: string): Promise<void> {
const host = describeRelayHosts(this.hosts).find(
(entry) => entry.environmentId === environmentId
)
if (!host) {
return Promise.resolve()
}
const key = `${host.environmentId}\0${relayHostFence(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
}
rows(environmentId: string, fence: string): readonly MobileRelayServerWorktreeRow[] | null {
const cached = this.cached.get(environmentId)
return cached && cached.fence === fence ? cached.worktrees : null
}
private async fetchAsDesktop(host: DescribedRelayHost): Promise<void> {
try {
const response = await this.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
// Why: a removed server's late answer must not outlive it.
if (
!reply?.success ||
!this.hosts.list().environments.some((entry) => entry.id === host.environmentId)
) {
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: relayHostFence(host.identity),
worktrees: stampServerWorktreeRows(host.environmentId, reply.data.worktrees)
})
} catch {
// An unreachable server keeps its last rows.
}
}
}
@@ -0,0 +1,21 @@
import { z } from 'zod'
import { toRuntimeExecutionHostId } from '../../../shared/execution-host'
// Why the phone's own full-catalog limit: the server's default page is 200 and nothing pages on.
export 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
export const WorktreePsReplySchema = z.object({ worktrees: z.array(z.looseObject({})) })
/**
* 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.
*/
export 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 }))
}
+27 -11
View File
@@ -1,7 +1,12 @@
import type { RuntimeLinearCommandSurface } from './runtime-linear-command-surface'
import { installRuntimeLinearCommandSurface } from './runtime-linear-command-surface'
import { publishWorkspaceViewChanges } from './runtime-workspace-view-change-events'
import { OrcaRuntimeWithWorkspaceReviewFacts } from './sidebar-inputs/orca-runtime-workspace-review-facts'
import {
OrcaRuntimeWithWorkspaceReviewFacts,
sidebarFactsRuntime
} from './sidebar-inputs/orca-runtime-workspace-review-facts'
import { OrcaRuntimeWithMigrationCatalog } from './orca-runtime-migration-catalog'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../shared/sidebar-inputs-switch'
import type { RuntimeCommandSurfaceHost } from './orca-runtime-core'
import type {
AgentLaunchTabPublished,
@@ -35,16 +40,27 @@ import { PairedServerRowsProducer } from './sidebar-inputs/paired-server-rows-pr
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-session-contracts'
import { getHostDisplayLabelOverrides } from '../../shared/host-setting-overrides'
// Switch point: a window-fed build keeps no sidebar facts layer.
const OrcaRuntimeSidebarBase: typeof OrcaRuntimeWithMigrationCatalog = ORCA_SIDEBAR_INPUTS_IS_MAIN
? OrcaRuntimeWithWorkspaceReviewFacts
: OrcaRuntimeWithMigrationCatalog
// oxlint-disable-next-line typescript/no-unsafe-declaration-merging -- SAFETY: The existing Linear installer supplies the canonical prototype surface.
class OrcaRuntimeService extends OrcaRuntimeWithWorkspaceReviewFacts {
// 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())
})
class OrcaRuntimeService extends OrcaRuntimeSidebarBase {
/** This runtime as its sidebar facts layer; null unless built with ORCA_SIDEBAR_INPUTS=main. */
get sidebarFacts() {
return sidebarFactsRuntime(this)
}
// Switch point. Idle until relay hosts attach; the fallback refresh runs while a window shows the sidebar.
readonly pairedServerRows = ORCA_SIDEBAR_INPUTS_IS_MAIN
? new PairedServerRowsProducer({
hasDemand: () =>
this.authoritativeWindowId !== null &&
this.authoritativeWindowId !== HEADLESS_RUNTIME_WINDOW_ID &&
this.store?.getUI?.().sidebarOpen !== false,
hostLabelOverrides: () => getHostDisplayLabelOverrides(this.getClientSettings())
})
: null
private async selectReferenceCatalogWorkspace(selector: string, cwd?: string) {
const catalog = listReferenceWorkspaces(
@@ -99,7 +115,7 @@ class OrcaRuntimeService extends OrcaRuntimeWithWorkspaceReviewFacts {
})
}
constructor(...args: ConstructorParameters<typeof OrcaRuntimeWithWorkspaceReviewFacts>) {
constructor(...args: ConstructorParameters<typeof OrcaRuntimeWithMigrationCatalog>) {
super(...args)
// Why: the runtime listing re-runs a scan the worktree-change generation overtook and re-lists
// through this runtime's scan cache, so a worktree change must reach both. The desktop IPC
+3 -1
View File
@@ -65,6 +65,7 @@ import { AGENT_HOOK_METHODS } from './agent-hooks'
import { AGENT_LAUNCH_METHODS } from './agent-launch'
import { LAYOUT_APPLY_METHODS, WORKSPACE_LAYOUT_METHODS } from './workspace-layout'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../shared/sidebar-inputs-switch'
// Why: a flat manifest keeps registration order explicit and provides one
// grep-point for "what methods does the RPC server expose?" — useful when
@@ -135,7 +136,8 @@ export const ALL_RPC_METHODS = [
...PAIRING_METHODS,
...MOBILE_RELAY_HOSTS_METHODS,
...MOBILE_PROJECT_SECTIONS_METHODS,
...SIDEBAR_INPUT_METHODS,
// Switch point: a window-fed build publishes no sidebar input.
...(ORCA_SIDEBAR_INPUTS_IS_MAIN ? SIDEBAR_INPUT_METHODS : []),
...UPDATER_METHODS,
...AUTOMATIC_WORK_METHODS
]
+13 -3
View File
@@ -3,12 +3,21 @@ import { getRegisteredSshState, listRegisteredSshTargets } from '../../../ssh/ss
import { getWslHostService } from '../../../wsl/wsl-host-service-instance'
import { readDesktopSidebarFacts } from '../../sidebar-inputs/desktop-sidebar-facts'
// Registered only with ORCA_SIDEBAR_INPUTS=main, so the producers below always exist.
function requireSwitchedOn<Producer>(producer: Producer | null): Producer {
if (producer === null) {
throw new Error('sidebar inputs are off in this build')
}
return producer
}
export const SIDEBAR_INPUT_METHODS = [
defineMethod({
name: 'sidebarInputs.activityFacts',
permission: 'workspace',
params: null,
handler: async (_params, { runtime }) => runtime.getWorkspaceActivityFacts()
handler: async (_params, { runtime }) =>
requireSwitchedOn(runtime.sidebarFacts).getWorkspaceActivityFacts()
}),
defineMethod({
name: 'sidebarInputs.desktopFacts',
@@ -26,12 +35,13 @@ export const SIDEBAR_INPUT_METHODS = [
name: 'sidebarInputs.pairedServerRows',
permission: 'workspace',
params: null,
handler: async (_params, { runtime }) => runtime.pairedServerRows.snapshot()
handler: async (_params, { runtime }) => requireSwitchedOn(runtime.pairedServerRows).snapshot()
}),
defineMethod({
name: 'sidebarInputs.reviewFacts',
permission: 'workspace',
params: null,
handler: async (_params, { runtime }) => runtime.getWorkspaceReviewFacts()
handler: async (_params, { runtime }) =>
requireSwitchedOn(runtime.sidebarFacts).getWorkspaceReviewFacts()
})
]
@@ -11,6 +11,7 @@ import { RuntimeRpcNetworkExposure } from './runtime-rpc-network-exposure'
import { MobileDesktopRelay } from '../mobile-desktop-relay/mobile-desktop-relay'
import type { MobileDesktopRelayHosts } from '../mobile-desktop-relay/mobile-desktop-relay-hosts'
import { MobileRelayHostCatalog } from '../mobile-desktop-relay/mobile-relay-host-catalog'
import { OnDemandServerWorktreeRows } from '../mobile-desktop-relay/on-demand-server-worktree-rows'
import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides'
import { DELEGATED_PHONE_NAME_MAX_CHARS } from '../../../shared/delegated-mobile-device-contract'
import { allocateTerminalSubscriptionStreamId } from '../rpc/methods/terminal/terminal-subscription-stream-id'
@@ -102,12 +103,13 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure {
nativeChatDefault: (agent) => isStructuredNativeChatDefaultFor(this.runtime, agent)
})
: null
const { pairedServerRows } = this.runtime
this.detachPairedServerRows?.()
this.detachPairedServerRows = hosts ? this.runtime.pairedServerRows.attach(hosts) : null
this.detachPairedServerRows = hosts && pairedServerRows ? pairedServerRows.attach(hosts) : null
this.mobileRelayHostCatalog = hosts
? new MobileRelayHostCatalog({
hosts,
serverRows: this.runtime.pairedServerRows,
serverRows: pairedServerRows ?? new OnDemandServerWorktreeRows(hosts),
hostLabelOverrides: () => getHostDisplayLabelOverrides(this.runtime.getClientSettings()),
getWorkspaceSession: (hostId) => this.runtime.getWorkspaceSessionForHost(hostId)
})
@@ -4,12 +4,18 @@ import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-t
import { AgentHookServer } from '../../agent-hooks/server'
import { OrcaRuntimeService } from '../orca-runtime'
vi.mock('../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true }))
const WT = 'repo-1::/tmp/worktree-a'
const FOLDER = 'folder:11111111-1111-4111-8111-111111111111'
const LEAF = '22222222-2222-4222-8222-222222222222'
function createRuntime(): OrcaRuntimeService {
return new OrcaRuntimeService()
function createRuntime() {
const runtime = new OrcaRuntimeService().sidebarFacts
if (!runtime) {
throw new Error('built without the sidebar facts layer')
}
return runtime
}
function snapshot(
@@ -1,11 +1,21 @@
// Runs on plain Node, as orcad does: facts come from the host's persisted PR cache.
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from '../orca-runtime'
import type { PRInfo } from '../../../shared/github/pull-request-types'
import type { RuntimeStore } from '../runtime-store-contract'
import type { ResolvedWorktreeSnapshot } from '../runtime-resolved-worktree-cache'
import type { ResolvedWorktree } from '../runtime-worktree-path-identity'
vi.mock('../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true }))
function withSidebarFacts(runtime: OrcaRuntimeService) {
const facts = runtime.sidebarFacts
if (!facts) {
throw new Error('built without the sidebar facts layer')
}
return facts
}
const repo = { id: 'repo-1', path: '/r', displayName: 'r', badgeColor: 'blue', addedAt: 1 }
function worktree(id: string, branch: string): ResolvedWorktree {
@@ -61,14 +71,16 @@ describe('getWorkspaceReviewFacts', () => {
}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the read touches only repos and the GitHub cache.
const runtime = new Probe(store as RuntimeStore)
const runtime = withSidebarFacts(new Probe(store as RuntimeStore))
await expect(runtime.getWorkspaceReviewFacts()).resolves.toEqual({
facts: { 'repo-1::/r/a': { provider: 'github', reviewState: 'merged' } }
})
})
it('publishes nothing without a store', async () => {
await expect(new OrcaRuntimeService().getWorkspaceReviewFacts()).resolves.toEqual({
await expect(
withSidebarFacts(new OrcaRuntimeService()).getWorkspaceReviewFacts()
).resolves.toEqual({
facts: {}
})
})
@@ -1,4 +1,6 @@
import { OrcaRuntimeWithWorkspaceActivityFacts } from './orca-runtime-workspace-activity-facts'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../shared/sidebar-inputs-switch'
import type { OrcaRuntimeWithMigrationCatalog } from '../orca-runtime-migration-catalog'
import {
buildWorkspaceReviewFacts,
type WorkspaceReviewFactsSnapshot
@@ -24,3 +26,12 @@ export abstract class OrcaRuntimeWithWorkspaceReviewFacts extends OrcaRuntimeWit
}
}
}
export function sidebarFactsRuntime(
runtime: OrcaRuntimeWithMigrationCatalog
): OrcaRuntimeWithWorkspaceReviewFacts | null {
// Why the switch first: it lets a switch-off build fold the instanceof away.
return ORCA_SIDEBAR_INPUTS_IS_MAIN && runtime instanceof OrcaRuntimeWithWorkspaceReviewFacts
? runtime
: null
}
@@ -1,4 +1,3 @@
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'
@@ -15,16 +14,17 @@ import {
describeRelayHosts,
relayHostFence
} from '../mobile-desktop-relay/mobile-relay-host-descriptions'
import type { ServerWorktreeRows } from '../mobile-desktop-relay/mobile-relay-host-catalog'
import {
SERVER_FETCH_TIMEOUT_MS,
SERVER_WORKTREE_LIMIT,
WorktreePsReplySchema,
stampServerWorktreeRows
} from '../mobile-desktop-relay/server-worktree-fetch'
// 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
@@ -46,7 +46,7 @@ type ServerEntry = {
* 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 {
export class PairedServerRowsProducer implements ServerWorktreeRows {
private readonly servers = new Map<string, ServerEntry>()
private hosts: MobileDesktopRelayHosts | null = null
private revision = 0
@@ -248,15 +248,3 @@ export class PairedServerRowsProducer {
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 }))
}
@@ -0,0 +1,13 @@
// A build without ORCA_SIDEBAR_INPUTS=main (every shipped one) keeps none of main's sidebar inputs.
import { describe, expect, it } from 'vitest'
import { OrcaRuntimeService } from '../orca-runtime'
import { ALL_RPC_METHODS } from '../rpc/methods'
describe('sidebar inputs with the switch off', () => {
it('builds no producer and registers no sidebarInputs method', () => {
const runtime = new OrcaRuntimeService()
expect(runtime.sidebarFacts).toBeNull()
expect(runtime.pairedServerRows).toBeNull()
expect(ALL_RPC_METHODS.filter((method) => method.name.startsWith('sidebarInputs.'))).toEqual([])
})
})
@@ -146,7 +146,7 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService {
recordDurableCrashBreadcrumb('agent_state_rules_active', rules)
)
state.runtime = runtime
runtime.workspaceActivityFacts.attachAgentStatusStore(agentHookServer)
runtime.sidebarFacts?.workspaceActivityFacts.attachAgentStatusStore(agentHookServer)
agentHookServer.subscribeEnrichedStatus((enriched) =>
recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched.paneKey, runtime)
)
+12 -8
View File
@@ -26,6 +26,7 @@ import { SkillWarningPreviewLauncher } from './components/skills/SkillWarningPre
import { installBrowserClientPageRenderer } from './components/browser-pane/browser-client-page-renderer-installation'
import { installOsFileDropCancellationGuard } from './lib/os-file-drop-cancellation-guard'
import { installSidebarShadowComparison } from './lib/sidebar-shadow/sidebar-shadow-comparison'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../shared/sidebar-inputs-switch'
import { workspaceActivityShadow } from './lib/sidebar-shadow/workspace-activity-shadow'
import {
pairedServerHostsShadow,
@@ -40,14 +41,17 @@ recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.e
installRendererCrashDiagnostics()
installTypingLatencyDiagnostic()
installAutomationHostDiagnostic()
const disposeSidebarShadow = installSidebarShadowComparison([
workspaceActivityShadow,
pairedServerRowsShadow,
pairedServerHostsShadow,
desktopFactsShadow,
workspaceReviewShadow
])
import.meta.hot?.dispose(disposeSidebarShadow)
// Switch point: only a build where main produces the sidebar's inputs compares them.
if (ORCA_SIDEBAR_INPUTS_IS_MAIN) {
const disposeSidebarShadow = installSidebarShadowComparison([
workspaceActivityShadow,
pairedServerRowsShadow,
pairedServerHostsShadow,
desktopFactsShadow,
workspaceReviewShadow
])
import.meta.hot?.dispose(disposeSidebarShadow)
}
if (
import.meta.env.DEV &&
+13
View File
@@ -0,0 +1,13 @@
// The one read of the ORCA_SIDEBAR_INPUTS build switch: 'main' turns on the dark path where main
// produces the sidebar's inputs. Off in every shipped build; deleted once the sidebar reads main.
// Every switch point branches on ORCA_SIDEBAR_INPUTS_IS_MAIN, so grep finds them all. A new main
// producer is a runtime field, `ORCA_SIDEBAR_INPUTS_IS_MAIN ? new Producer() : null` (a layer class
// only if it must override runtime hooks); its RPC joins SIDEBAR_INPUT_METHODS and its renderer
// shadow joins the gated list in main.tsx.
/** Substituted by electron.vite.config.ts; absent (off) in tests, the CLI and the web client. */
declare const ORCA_SIDEBAR_INPUTS: 'window' | 'main' | undefined
export const ORCA_SIDEBAR_INPUTS_IS_MAIN =
// oxlint-disable-next-line unicorn/no-typeof-undefined -- undeclared outside electron-vite builds; a bare read throws.
typeof ORCA_SIDEBAR_INPUTS !== 'undefined' && ORCA_SIDEBAR_INPUTS === 'main'
+15 -1
View File
@@ -1,8 +1,22 @@
import { existsSync } from 'node:fs'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { expect } from './orca-app'
/** Main's sidebar inputs agree with what the window derives itself (dev/test shadow mode). */
/** Whether out/ holds an ORCA_SIDEBAR_INPUTS=main build (config/dark-build-switches.cjs marks it). */
export const SIDEBAR_INPUTS_BUILD = existsSync(
path.join(process.cwd(), 'out', 'main', 'ORCA_SIDEBAR_INPUTS_MAIN')
)
/**
* Main's sidebar inputs agree with what the window derives itself (dev/test shadow mode). Without
* an ORCA_SIDEBAR_INPUTS=main build there is nothing to compare, and no comparison may run.
*/
export async function expectNoSidebarShadowMismatches(page: Page): Promise<void> {
if (!SIDEBAR_INPUTS_BUILD) {
expect(await page.evaluate(() => window.__sidebarShadow === undefined)).toBe(true)
return
}
const comparisons = (): Promise<number> =>
page.evaluate(() => window.__sidebarShadow?.comparisons() ?? -1)
const start = await comparisons()
+6 -1
View File
@@ -9,9 +9,14 @@ 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 { SIDEBAR_INPUTS_BUILD, expectNoSidebarShadowMismatches } from './helpers/sidebar-shadow'
import type { Page } from '@stablyai/playwright-test'
test.skip(
!SIDEBAR_INPUTS_BUILD,
'main publishes sidebar inputs only in an ORCA_SIDEBAR_INPUTS=main build'
)
const SnapshotSchema = z.object({
servers: z.array(
z.object({
+6 -1
View File
@@ -1,9 +1,14 @@
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { expectNoSidebarShadowMismatches } from './helpers/sidebar-shadow'
import { SIDEBAR_INPUTS_BUILD, expectNoSidebarShadowMismatches } from './helpers/sidebar-shadow'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { WorkspaceReviewFactsSnapshotSchema } from '../../src/shared/sidebar-list/workspace-review-facts'
test.skip(
!SIDEBAR_INPUTS_BUILD,
'main publishes sidebar inputs only in an ORCA_SIDEBAR_INPUTS=main build'
)
/** Writes a PR into the window's cache the way a fetch result lands; returns the row's id. */
async function setWindowPRState(page: Page, state: 'open' | 'merged' | null): Promise<string> {
return page.evaluate((state) => {