mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
feat(sidebar): put main's sidebar inputs behind the ORCA_SIDEBAR_INPUTS dark build switch (#30050)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user