From 6fcc183ab951fb751cf02ccb9e2af3cf129a4316 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:06:43 -0700 Subject: [PATCH] feat(sidebar): put main's sidebar inputs behind the ORCA_SIDEBAR_INPUTS dark build switch (#30050) --- config/dark-build-switches.cjs | 70 +++ config/electron-builder.config.cjs | 4 +- config/layout-owner-build.cjs | 39 -- config/scripts/build-orcad-template.mjs | 4 +- config/scripts/dark-build-switches.test.mjs | 51 ++ .../scripts/generate-rpc-params-catalog.mjs | 4 +- .../headless-detector-compiler-cache.test.mjs | 4 +- config/scripts/layout-owner-build.test.mjs | 34 -- config/scripts/orcad-entry-build.mjs | 6 +- electron.vite.config.ts | 32 +- src/main/orcad/orcad-entry.ts | 6 +- src/main/orcad/orcad-push-startup.test.ts | 2 +- .../mobile-relay-host-catalog.ts | 19 +- .../on-demand-server-worktree-rows.test.ts | 518 ++++++++++++++++++ .../on-demand-server-worktree-rows.ts | 87 +++ .../server-worktree-fetch.ts | 21 + src/main/runtime/orca-runtime.ts | 38 +- src/main/runtime/rpc/methods/index.ts | 4 +- .../runtime/rpc/methods/sidebar-inputs.ts | 16 +- .../runtime-rpc/runtime-rpc-pairing.ts | 6 +- ...a-runtime-workspace-activity-facts.test.ts | 10 +- ...rca-runtime-workspace-review-facts.test.ts | 18 +- .../orca-runtime-workspace-review-facts.ts | 11 + .../paired-server-rows-producer.ts | 28 +- .../sidebar-inputs-switch-off.test.ts | 13 + .../startup/main-process-runtime-service.ts | 2 +- src/renderer/src/main.tsx | 20 +- src/shared/sidebar-inputs-switch.ts | 13 + tests/e2e/helpers/sidebar-shadow.ts | 16 +- tests/e2e/paired-server-rows-shadow.spec.ts | 7 +- tests/e2e/sidebar-review-facts.spec.ts | 7 +- 31 files changed, 945 insertions(+), 165 deletions(-) create mode 100644 config/dark-build-switches.cjs delete mode 100644 config/layout-owner-build.cjs create mode 100644 config/scripts/dark-build-switches.test.mjs delete mode 100644 config/scripts/layout-owner-build.test.mjs create mode 100644 src/main/runtime/mobile-desktop-relay/on-demand-server-worktree-rows.test.ts create mode 100644 src/main/runtime/mobile-desktop-relay/on-demand-server-worktree-rows.ts create mode 100644 src/main/runtime/mobile-desktop-relay/server-worktree-fetch.ts create mode 100644 src/main/runtime/sidebar-inputs/sidebar-inputs-switch-off.test.ts create mode 100644 src/shared/sidebar-inputs-switch.ts diff --git a/config/dark-build-switches.cjs b/config/dark-build-switches.cjs new file mode 100644 index 00000000000..5b5cb5c0283 --- /dev/null +++ b/config/dark-build-switches.cjs @@ -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 +} diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index a2ee7a0db5f..3a9ef7e36f6 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -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() diff --git a/config/layout-owner-build.cjs b/config/layout-owner-build.cjs deleted file mode 100644 index 4a71f5c8b83..00000000000 --- a/config/layout-owner-build.cjs +++ /dev/null @@ -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 -} diff --git a/config/scripts/build-orcad-template.mjs b/config/scripts/build-orcad-template.mjs index 4a2582798db..f5b2a955275 100644 --- a/config/scripts/build-orcad-template.mjs +++ b/config/scripts/build-orcad-template.mjs @@ -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( diff --git a/config/scripts/dark-build-switches.test.mjs b/config/scripts/dark-build-switches.test.mjs new file mode 100644 index 00000000000..3b435aa4a6a --- /dev/null +++ b/config/scripts/dark-build-switches.test.mjs @@ -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) + } + ) +}) diff --git a/config/scripts/generate-rpc-params-catalog.mjs b/config/scripts/generate-rpc-params-catalog.mjs index 423086c3fd0..77a1e8d5e7c 100644 --- a/config/scripts/generate-rpc-params-catalog.mjs +++ b/config/scripts/generate-rpc-params-catalog.mjs @@ -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 } diff --git a/config/scripts/headless-detector-compiler-cache.test.mjs b/config/scripts/headless-detector-compiler-cache.test.mjs index ffec74d6df0..451aa4be142 100644 --- a/config/scripts/headless-detector-compiler-cache.test.mjs +++ b/config/scripts/headless-detector-compiler-cache.test.mjs @@ -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( diff --git a/config/scripts/layout-owner-build.test.mjs b/config/scripts/layout-owner-build.test.mjs deleted file mode 100644 index 035ac617ef0..00000000000 --- a/config/scripts/layout-owner-build.test.mjs +++ /dev/null @@ -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) - }) -}) diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index 1830b98ea40..bf99500e14e 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -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' } } diff --git a/electron.vite.config.ts b/electron.vite.config.ts index ed7d26c058a..19362ba664d 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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 + 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: { diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 22d5f8dc02e..a6c4369897d 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -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 diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index e45eb651159..05204f74bf4 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -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' } diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts index cff3307a8f0..5220dc0ca01 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts @@ -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 + /** 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 /** The desktop's saved session for a host, as its sidebar last wrote it. */ getWorkspaceSession: (hostId: ExecutionHostId) => WorkspaceSessionState | null diff --git a/src/main/runtime/mobile-desktop-relay/on-demand-server-worktree-rows.test.ts b/src/main/runtime/mobile-desktop-relay/on-demand-server-worktree-rows.test.ts new file mode 100644 index 00000000000..cf1ac33cf71 --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/on-demand-server-worktree-rows.test.ts @@ -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[]): RuntimeRpcResponse { + 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(), + sshConnectionStates: new Map() + } + const call = vi.fn() + 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> + } = {} +) { + 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) => 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) => 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) => 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[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>, 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']) + }) +}) diff --git a/src/main/runtime/mobile-desktop-relay/on-demand-server-worktree-rows.ts b/src/main/runtime/mobile-desktop-relay/on-demand-server-worktree-rows.ts new file mode 100644 index 00000000000..7691d318f33 --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/on-demand-server-worktree-rows.ts @@ -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() + // Keyed by server identity, so a poll after a re-pair never joins the old server's fetch. + private readonly refreshing = new Map>() + + constructor(private readonly 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 { + 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 { + 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. + } + } +} diff --git a/src/main/runtime/mobile-desktop-relay/server-worktree-fetch.ts b/src/main/runtime/mobile-desktop-relay/server-worktree-fetch.ts new file mode 100644 index 00000000000..4c26313bc4b --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/server-worktree-fetch.ts @@ -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( + environmentId: string, + rows: readonly Row[] +): (Row & { hostId: `runtime:${string}` })[] { + const hostId = toRuntimeExecutionHostId(environmentId) + return rows.map((row) => ({ ...row, hostId })) +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 27ec067ef84..f5f1bca3331 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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) { + constructor(...args: ConstructorParameters) { 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 diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index e74d6e37799..3a219e8f33c 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -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 ] diff --git a/src/main/runtime/rpc/methods/sidebar-inputs.ts b/src/main/runtime/rpc/methods/sidebar-inputs.ts index b4b20627d66..d8ee5ab16ad 100644 --- a/src/main/runtime/rpc/methods/sidebar-inputs.ts +++ b/src/main/runtime/rpc/methods/sidebar-inputs.ts @@ -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 | 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() }) ] diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index 7a8a3d00e00..03994c4f8a4 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -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) }) diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-workspace-activity-facts.test.ts b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-activity-facts.test.ts index 5a66ab56526..da6052625c4 100644 --- a/src/main/runtime/sidebar-inputs/orca-runtime-workspace-activity-facts.test.ts +++ b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-activity-facts.test.ts @@ -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( diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-workspace-review-facts.test.ts b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-review-facts.test.ts index 5aed0303b49..e6c3893122c 100644 --- a/src/main/runtime/sidebar-inputs/orca-runtime-workspace-review-facts.test.ts +++ b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-review-facts.test.ts @@ -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: {} }) }) diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-workspace-review-facts.ts b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-review-facts.ts index 785fe924565..d3550ac4039 100644 --- a/src/main/runtime/sidebar-inputs/orca-runtime-workspace-review-facts.ts +++ b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-review-facts.ts @@ -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 +} diff --git a/src/main/runtime/sidebar-inputs/paired-server-rows-producer.ts b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.ts index 080d0783368..d788d282d95 100644 --- a/src/main/runtime/sidebar-inputs/paired-server-rows-producer.ts +++ b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.ts @@ -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() 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( - environmentId: string, - rows: readonly Row[] -): (Row & { hostId: `runtime:${string}` })[] { - const hostId = toRuntimeExecutionHostId(environmentId) - return rows.map((row) => ({ ...row, hostId })) -} diff --git a/src/main/runtime/sidebar-inputs/sidebar-inputs-switch-off.test.ts b/src/main/runtime/sidebar-inputs/sidebar-inputs-switch-off.test.ts new file mode 100644 index 00000000000..d566cdaa784 --- /dev/null +++ b/src/main/runtime/sidebar-inputs/sidebar-inputs-switch-off.test.ts @@ -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([]) + }) +}) diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 12ab71c7773..510bb1433c7 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -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) ) diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a31220ff135..aa92b322d6f 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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 && diff --git a/src/shared/sidebar-inputs-switch.ts b/src/shared/sidebar-inputs-switch.ts new file mode 100644 index 00000000000..c3ec48462b5 --- /dev/null +++ b/src/shared/sidebar-inputs-switch.ts @@ -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' diff --git a/tests/e2e/helpers/sidebar-shadow.ts b/tests/e2e/helpers/sidebar-shadow.ts index 48a62aaa0b0..2942b1e53cd 100644 --- a/tests/e2e/helpers/sidebar-shadow.ts +++ b/tests/e2e/helpers/sidebar-shadow.ts @@ -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 { + if (!SIDEBAR_INPUTS_BUILD) { + expect(await page.evaluate(() => window.__sidebarShadow === undefined)).toBe(true) + return + } const comparisons = (): Promise => page.evaluate(() => window.__sidebarShadow?.comparisons() ?? -1) const start = await comparisons() diff --git a/tests/e2e/paired-server-rows-shadow.spec.ts b/tests/e2e/paired-server-rows-shadow.spec.ts index 06c43d4f20e..39cadc24d6b 100644 --- a/tests/e2e/paired-server-rows-shadow.spec.ts +++ b/tests/e2e/paired-server-rows-shadow.spec.ts @@ -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({ diff --git a/tests/e2e/sidebar-review-facts.spec.ts b/tests/e2e/sidebar-review-facts.spec.ts index 4ac1c7ec9a6..0138d2e2889 100644 --- a/tests/e2e/sidebar-review-facts.spec.ts +++ b/tests/e2e/sidebar-review-facts.spec.ts @@ -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 { return page.evaluate((state) => {