From 71be80ca8a95b5f55b673a5fd4821f1c0f382ad5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:26:57 -0700 Subject: [PATCH] fix(automations): list a web client's paired server once, not again as Desktop (#33019) * fix(automations): list a web client's paired server once, not again as Desktop A paired web client's local runtime calls go to its server, so the desktop authority and the saved runtime are one runtime. The Automations catalog projected both and every automation appeared twice (Local + Orca Server). The catalog now folds the desktop authority into the saved runtime that getPairedWebClientEnvironmentId names, and a saved Desktop host filter falls back to All hosts instead of waiting forever for an entry that never comes. Co-authored-by: Chen * fix(automations): move a saved Desktop host filter onto the paired server On a paired web client the Desktop host is the server, so a saved Desktop selection now names the server's matching host instead of being dropped with a fallback notice on every visit. Co-authored-by: Chen * fix(automations): route web run links and sole-host choice through the folded server Codex review: with Desktop folded into the paired server, a workspace run link carrying hostId 'local' never matched a row and the run page stayed loading, and the sole create host could be picked before the server's SSH targets loaded. Pending navigation on a paired web client now names the server's runtime, and desktop SSH hydration follows the runtime Desktop folded into. Co-authored-by: Chen * fix(automations): match web run links against the folded server at use time Resolving the pending navigation's host when it is matched, not when it is stored, also covers a link opened before the server environment list loads. Replaces the store-setter rewrite from the previous commit. Co-authored-by: Chen --------- Co-authored-by: Chen --- .../automation-host-catalog-source.ts | 5 +++ .../automation-host-catalog-types.ts | 5 +++ .../automation-host-catalog.test.ts | 12 +++++ .../automations/automation-host-catalog.ts | 29 +++++++----- .../automation-host-client.test.ts | 24 ++++++++++ .../automations/automation-host-client.ts | 14 ++++++ .../automation-host-filter-resolution.ts | 30 +++++++++++++ ...se-automation-host-catalog-source-state.ts | 10 +++-- .../use-automation-host-catalog.test.tsx | 45 +++++++++++++++++++ .../use-automation-host-catalog.ts | 11 +++-- .../use-automation-run-page-state.ts | 6 ++- .../use-automations-page-refresh.ts | 24 +++++++--- 12 files changed, 190 insertions(+), 25 deletions(-) diff --git a/src/renderer/src/components/automations/automation-host-catalog-source.ts b/src/renderer/src/components/automations/automation-host-catalog-source.ts index 670fe4b175c..7092bbb9545 100644 --- a/src/renderer/src/components/automations/automation-host-catalog-source.ts +++ b/src/renderer/src/components/automations/automation-host-catalog-source.ts @@ -43,6 +43,8 @@ export type AutomationHostCatalogSource = { desktopLabel?: string desktopSsh: AutomationCatalogSshSource runtimes: readonly AutomationCatalogRuntimeSource[] + /** The saved runtime this client's local transport reaches, when it has no runtime of its own. */ + desktopAliasEnvironmentId?: string | null runtimeCatalogSettled: boolean referencedStableKeys?: Iterable /** Authoritative orphan counts the cache has seen, keyed by authority catalog key. */ @@ -143,6 +145,9 @@ export function buildAutomationHostCatalogSource( ...withOrphanCount(orphanCountFor(source, authority)) } }), + ...(source.desktopAliasEnvironmentId + ? { desktopAliasEnvironmentId: source.desktopAliasEnvironmentId } + : {}), runtimeCatalogSettled: source.runtimeCatalogSettled, ...(source.referencedStableKeys ? { referencedStableKeys: source.referencedStableKeys } : {}) } diff --git a/src/renderer/src/components/automations/automation-host-catalog-types.ts b/src/renderer/src/components/automations/automation-host-catalog-types.ts index 4aa37d0617c..bf639dc956d 100644 --- a/src/renderer/src/components/automations/automation-host-catalog-types.ts +++ b/src/renderer/src/components/automations/automation-host-catalog-types.ts @@ -108,6 +108,11 @@ export type AutomationHostCatalogInput = { desktop: AutomationDesktopAuthorityInput /** Saved runtime environments — projected even while offline. */ runtimes: readonly AutomationRuntimeAuthorityInput[] + /** + * The saved runtime that *is* the desktop authority: a paired web client's local + * transport is its server's, so projecting both would list every record twice. + */ + desktopAliasEnvironmentId?: string /** Gates Runtime+Self absence; a failed catalog read settles without hydrating. */ runtimeCatalogSettled: boolean /** Stable keys still referenced by a stored automation, cached row, persisted filter, or tombstone. */ diff --git a/src/renderer/src/components/automations/automation-host-catalog.test.ts b/src/renderer/src/components/automations/automation-host-catalog.test.ts index 2eae8530b02..57aa11f8ac7 100644 --- a/src/renderer/src/components/automations/automation-host-catalog.test.ts +++ b/src/renderer/src/components/automations/automation-host-catalog.test.ts @@ -95,6 +95,18 @@ describe('buildAutomationHostCatalog', () => { expect(self.owner).toEqual({ authority: { kind: 'desktop' }, selector: { kind: 'self' } }) }) + it('folds Desktop into its alias runtime and waits on that runtime for SSH hydration', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + desktopAliasEnvironmentId: 'env-1', + runtimes: [runtime({ ssh: mirror({ targetsHydrated: false }) })] + }) + ) + + expect(catalog.entries.map((entry) => entry.stableKey)).toEqual([runtimeSelfKey('env-1')]) + expect(catalog.hydration.desktopSshHydrated).toBe(false) + }) + it('keeps equal SSH target ids under Desktop and a runtime distinct', () => { const catalog = buildAutomationHostCatalog( buildInput({ diff --git a/src/renderer/src/components/automations/automation-host-catalog.ts b/src/renderer/src/components/automations/automation-host-catalog.ts index 94892971bb1..5f29e6afec6 100644 --- a/src/renderer/src/components/automations/automation-host-catalog.ts +++ b/src/renderer/src/components/automations/automation-host-catalog.ts @@ -248,17 +248,23 @@ export function buildAutomationHostCatalog( ): AutomationHostCatalog { const referenced = groupReferencedKeys(input.referencedStableKeys) const desktopAuthority: StableAutomationAuthorityRef = { kind: 'desktop' } + // Only a saved runtime can absorb the desktop authority; an unknown id leaves it projected. + const desktopAlias = input.runtimes.find( + (runtime) => runtime.environmentId === input.desktopAliasEnvironmentId + ) + const desktopAliasEnvironmentId = desktopAlias?.environmentId + const desktopContext: AuthorityProjectionContext = { + authority: desktopAuthority, + authorityLabel: input.desktop.label, + authorityHealth: input.desktop.authorityHealth ?? 'fresh', + querySupport: 'scoped', + pairingRevision: null, + ssh: input.desktop.ssh, + referenced: referenced.get(automationAuthorityCatalogKey(desktopAuthority)) ?? EMPTY_SCOPE, + orphanCount: input.desktop.orphanCount + } const contexts: AuthorityProjectionContext[] = [ - { - authority: desktopAuthority, - authorityLabel: input.desktop.label, - authorityHealth: input.desktop.authorityHealth ?? 'fresh', - querySupport: 'scoped', - pairingRevision: null, - ssh: input.desktop.ssh, - referenced: referenced.get(automationAuthorityCatalogKey(desktopAuthority)) ?? EMPTY_SCOPE, - orphanCount: input.desktop.orphanCount - }, + ...(desktopAliasEnvironmentId === undefined ? [desktopContext] : []), ...input.runtimes.map((runtime): AuthorityProjectionContext => { const authority: StableAutomationAuthorityRef = { kind: 'runtime', @@ -283,7 +289,8 @@ export function buildAutomationHostCatalog( byStableKey, hydration: { runtimeCatalogSettled: input.runtimeCatalogSettled, - desktopSshHydrated: input.desktop.ssh.targetsHydrated, + // A folded desktop's SSH targets are the runtime it folded into. + desktopSshHydrated: (desktopAlias?.ssh ?? input.desktop.ssh).targetsHydrated, runtimeSshHydratedByEnvironmentId: new Map( input.runtimes.map((runtime) => [runtime.environmentId, runtime.ssh.targetsHydrated]) ), diff --git a/src/renderer/src/components/automations/automation-host-client.test.ts b/src/renderer/src/components/automations/automation-host-client.test.ts index d2da4c40b01..5ba10763dce 100644 --- a/src/renderer/src/components/automations/automation-host-client.test.ts +++ b/src/renderer/src/components/automations/automation-host-client.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import type { Automation, AutomationCreateInput } from '../../../../shared/automations-types' import { + getAutomationListedTargetFromHostId, listAutomationRunsForTarget, listAutomationsForTarget, runAutomationNowForTarget, @@ -192,3 +193,26 @@ describe('automation host client', () => { ) }) }) + +describe('getAutomationListedTargetFromHostId', () => { + const state = { runtimeEnvironments: [{ id: 'env-1' }] } + + // #32415: a web run link names 'local', but the catalog lists the server only as its runtime. + it('lists a paired web client local and SSH hosts under its server', () => { + Object.assign(globalThis, { __ORCA_WEB_CLIENT__: true }) + try { + for (const hostId of ['local', 'ssh:box', undefined]) { + expect(getAutomationListedTargetFromHostId(hostId, state)).toEqual({ + kind: 'environment', + environmentId: 'env-1' + }) + } + } finally { + Reflect.deleteProperty(globalThis, '__ORCA_WEB_CLIENT__') + } + }) + + it('keeps Desktop hosts local in the desktop app', () => { + expect(getAutomationListedTargetFromHostId('local', state)).toEqual({ kind: 'local' }) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index 7d2a85e3dab..20120f45a0e 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -1,4 +1,5 @@ import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { getPairedWebClientEnvironmentId } from '@/lib/paired-web-client-host' import { assertEnvironmentSupportsExtraAgentArgs } from './automation-capability-probe' import type { Automation, @@ -51,6 +52,19 @@ export function getAutomationTargetFromHostId( : { kind: 'local' } } +/** + * The authority a host id is listed under on this client. A paired web client's local and SSH + * hosts are its server's, which the catalog lists only as that runtime. + */ +export function getAutomationListedTargetFromHostId( + hostId: string | null | undefined, + state: { runtimeEnvironments?: readonly { id: string }[] } +): AutomationHostTarget { + const target = getAutomationTargetFromHostId(hostId) + const environmentId = target.kind === 'local' ? getPairedWebClientEnvironmentId(state) : null + return environmentId ? { kind: 'environment', environmentId } : target +} + export function getAutomationAuthorityTarget( authority: StableAutomationAuthorityRef ): AutomationHostTarget { diff --git a/src/renderer/src/components/automations/automation-host-filter-resolution.ts b/src/renderer/src/components/automations/automation-host-filter-resolution.ts index 15350cb00b7..d0af7b3795f 100644 --- a/src/renderer/src/components/automations/automation-host-filter-resolution.ts +++ b/src/renderer/src/components/automations/automation-host-filter-resolution.ts @@ -49,6 +49,36 @@ export type AutomationHostFilterResolutionInput = { referencedStableKeys?: ReadonlySet } +/** + * A selection saved under a desktop authority that is really a saved runtime names that + * runtime's host: same machine, same targets, so it moves rather than being dropped. + */ +export function foldDesktopHostFilter( + filter: AutomationHostFilter, + desktopAliasEnvironmentId: string | null | undefined +): AutomationHostFilter { + if ( + filter.kind !== 'host' || + filter.host.authority.kind !== 'desktop' || + !desktopAliasEnvironmentId + ) { + return filter + } + const authority = { kind: 'runtime', environmentId: desktopAliasEnvironmentId } as const + // Rebuilt per variant: a spread selector widens `kind` and no longer discriminates. + switch (filter.host.selector.kind) { + case 'ssh': + return { + kind: 'host', + host: { authority, selector: { kind: 'ssh', targetId: filter.host.selector.targetId } } + } + case 'orphan': + return { kind: 'host', host: { authority, selector: { kind: 'orphan' } } } + case 'self': + return { kind: 'host', host: { authority, selector: { kind: 'self' } } } + } +} + const ALL_RESOLUTION: AutomationHostFilterResolution = { effective: ALL_AUTOMATION_HOSTS_FILTER, entry: null, diff --git a/src/renderer/src/components/automations/use-automation-host-catalog-source-state.ts b/src/renderer/src/components/automations/use-automation-host-catalog-source-state.ts index 21cbd45a4db..baa136af9b2 100644 --- a/src/renderer/src/components/automations/use-automation-host-catalog-source-state.ts +++ b/src/renderer/src/components/automations/use-automation-host-catalog-source-state.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react' import { useAppStore } from '@/store' +import { getPairedWebClientEnvironmentId } from '@/lib/paired-web-client-host' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import type { AutomationCatalogRuntimeSource, @@ -23,7 +24,7 @@ type AutomationHostCatalogSourceStateInput = { export type AutomationHostCatalogSourceState = Pick< AutomationHostCatalogSource, - 'desktopSsh' | 'runtimes' | 'runtimeCatalogSettled' + 'desktopSsh' | 'runtimes' | 'desktopAliasEnvironmentId' | 'runtimeCatalogSettled' > export function useAutomationHostCatalogSourceState({ @@ -81,9 +82,12 @@ export function useAutomationHostCatalogSourceState({ [runtimeEnvironments, runtimeStatusByEnvironmentId, sshStateByEnvironment] ) + // A web client's local calls are its paired server's, so Desktop and that runtime are one authority. + const desktopAliasEnvironmentId = getPairedWebClientEnvironmentId({ runtimeEnvironments }) + // Memoized: consumers key whole-catalog rebuilds on this object's identity. return useMemo( - () => ({ desktopSsh, runtimes, runtimeCatalogSettled }), - [desktopSsh, runtimes, runtimeCatalogSettled] + () => ({ desktopSsh, runtimes, desktopAliasEnvironmentId, runtimeCatalogSettled }), + [desktopSsh, runtimes, desktopAliasEnvironmentId, runtimeCatalogSettled] ) } diff --git a/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx b/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx index 74b59a4184e..de708605e4c 100644 --- a/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx +++ b/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx @@ -114,6 +114,10 @@ function Harness(): null { async function renderCatalog(repos: readonly Repo[]): Promise { mocks.state = storeState(repos) + await renderCatalogWithState() +} + +async function renderCatalogWithState(): Promise { const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) @@ -163,6 +167,7 @@ afterEach(async () => { await act(async () => { roots.splice(0).forEach((root) => root.unmount()) }) + Reflect.deleteProperty(globalThis, '__ORCA_WEB_CLIENT__') document.body.innerHTML = '' }) @@ -178,6 +183,46 @@ describe('useAutomationHostCatalog orphan bootstrap', () => { }) }) +describe('useAutomationHostCatalog on a paired web client', () => { + const runtimeSelf = { + authority: { kind: 'runtime', environmentId: ENVIRONMENT_ID }, + selector: { kind: 'self' } + } + + beforeEach(() => { + Object.assign(globalThis, { __ORCA_WEB_CLIENT__: true }) + }) + + // #32415: the browser's local transport is its server's, so Desktop listed the same records again. + it('lists the paired server once instead of again as Desktop', async () => { + await renderCatalog([repo({ id: PROJECT_ID, executionHostId: `runtime:${ENVIRONMENT_ID}` })]) + + expect(view?.catalog.entries.map((entry) => entry.stableRef)).toEqual([runtimeSelf]) + expect(view?.rows.groups.flatMap((group) => group.hosts.flatMap((host) => host.rows))).toEqual([ + expect.objectContaining({ automation: expect.objectContaining({ id: 'a-legacy' }) }) + ]) + expect(desktopListScoped).not.toHaveBeenCalled() + }) + + it('moves a saved Desktop filter to the server instead of waiting forever for Desktop', async () => { + mocks.state = { + ...storeState([]), + automationHostFilter: { + kind: 'host', + host: { authority: { kind: 'desktop' }, selector: { kind: 'self' } } + } + } + + await renderCatalogWithState() + + expect(view?.resolution).toMatchObject({ + status: 'ready', + announceFallback: false, + effective: { kind: 'host', host: runtimeSelf } + }) + }) +}) + describe('useAutomationHostCatalog legacy partitioning', () => { it('does not let a same-id desktop project claim an old runtime record for Self', async () => { await renderCatalog([repo({ id: PROJECT_ID })]) diff --git a/src/renderer/src/components/automations/use-automation-host-catalog.ts b/src/renderer/src/components/automations/use-automation-host-catalog.ts index e40a4393e20..f67ba085855 100644 --- a/src/renderer/src/components/automations/use-automation-host-catalog.ts +++ b/src/renderer/src/components/automations/use-automation-host-catalog.ts @@ -24,6 +24,7 @@ import type { } from './automation-host-catalog-types' import { automationHostFilterStableKey } from '../../../../shared/automation-host-filter' import { + foldDesktopHostFilter, resolveAutomationHostFilter, type AutomationHostFilterResolution } from './automation-host-filter-resolution' @@ -146,7 +147,11 @@ export function useAutomationHostCatalog( desktopSshGenerations, runtimeEnvironments }) - const selectedStableKey = automationHostFilterStableKey(automationHostFilter) + const hostFilter = useMemo( + () => foldDesktopHostFilter(automationHostFilter, sourceState.desktopAliasEnvironmentId), + [automationHostFilter, sourceState.desktopAliasEnvironmentId] + ) + const selectedStableKey = automationHostFilterStableKey(hostFilter) // The saved selection references itself: without this the catalog would omit a // removed host the user is still filtered to, and absence would read as removal. const referencedStableKeys = useMemo( @@ -206,7 +211,7 @@ export function useAutomationHostCatalog( const resolution = useMemo( () => resolveAutomationHostFilter({ - filter: automationHostFilter, + filter: hostFilter, catalog, // Deliberately not `referencedStableKeys`: that set includes the saved // selection so the catalog still projects it, and a selection that @@ -219,7 +224,7 @@ export function useAutomationHostCatalog( ]) }), // oxlint-disable-next-line react-hooks/exhaustive-deps -- cacheVersion is the cache's change signal; the cache object itself never changes identity. - [automationHostFilter, cacheVersion, catalog, controller, options.referencedStableKeys] + [cacheVersion, catalog, controller, hostFilter, options.referencedStableKeys] ) const rows = useMemo( () => diff --git a/src/renderer/src/components/automations/use-automation-run-page-state.ts b/src/renderer/src/components/automations/use-automation-run-page-state.ts index ba15e245314..56cc41b9757 100644 --- a/src/renderer/src/components/automations/use-automation-run-page-state.ts +++ b/src/renderer/src/components/automations/use-automation-run-page-state.ts @@ -4,7 +4,7 @@ import { translate } from '@/i18n/i18n' import { getAutomationAuthorityTarget, getAutomationHostTargetKey, - getAutomationTargetFromHostId + getAutomationListedTargetFromHostId } from './automation-host-client' import { ALL_AUTOMATION_HOSTS_FILTER } from '../../../../shared/automation-host-filter' import { useAppStore } from '@/store' @@ -37,6 +37,7 @@ export function useAutomationRunPageState({ worktreeForRow, pendingAutomationRunNavigation, setPendingAutomationRunNavigation, + runtimeEnvironments, selectedId, setSelectedId, unifiedTabsByWorktree, @@ -109,7 +110,7 @@ export function useAutomationRunPageState({ return } const pendingTargetKey = getAutomationHostTargetKey( - getAutomationTargetFromHostId(pending.hostId) + getAutomationListedTargetFromHostId(pending.hostId, { runtimeEnvironments }) ) if (automationHostTargetKey !== pendingTargetKey) { return @@ -202,6 +203,7 @@ export function useAutomationRunPageState({ visibleRows, pendingAutomationRunNavigation, pageView, + runtimeEnvironments, selectExternalKey, selectedExternalKey, selectedId, diff --git a/src/renderer/src/components/automations/use-automations-page-refresh.ts b/src/renderer/src/components/automations/use-automations-page-refresh.ts index 5bc1d6346a2..ea5b1b48a43 100644 --- a/src/renderer/src/components/automations/use-automations-page-refresh.ts +++ b/src/renderer/src/components/automations/use-automations-page-refresh.ts @@ -4,7 +4,7 @@ import { useAppStore } from '@/store' import { AUTOMATIONS_CHANGED_EVENT } from '@/lib/automations-changed-window-event' import { getAutomationHostTargetKey, - getAutomationTargetFromHostId, + getAutomationListedTargetFromHostId, listAutomationsForTarget } from './automation-host-client' import { automationAuthorityCatalogKey } from './automation-host-catalog-types' @@ -26,8 +26,12 @@ export function useAutomationsPageRefresh({ list: AutomationsPageListState destination: AutomationsPageDestinationState }) { - const { fetchAllWorktrees, startupWorktreeRefreshCompleted, pendingAutomationRunNavigation } = - store + const { + fetchAllWorktrees, + startupWorktreeRefreshCompleted, + pendingAutomationRunNavigation, + runtimeEnvironments + } = store const { setIsLoading, setAutomations, @@ -50,7 +54,7 @@ export function useAutomationsPageRefresh({ // Until a navigation names a host, the desktop is the only authority the // legacy unscoped arm can address without guessing. const target = pendingNavigation - ? getAutomationTargetFromHostId(pendingNavigation.hostId) + ? getAutomationListedTargetFromHostId(pendingNavigation.hostId, useAppStore.getState()) : { kind: 'local' as const } const authorityKey = automationAuthorityCatalogKey( target.kind === 'environment' @@ -117,12 +121,20 @@ export function useAutomationsPageRefresh({ return } const pendingTargetKey = getAutomationHostTargetKey( - getAutomationTargetFromHostId(pendingAutomationRunNavigation.hostId) + getAutomationListedTargetFromHostId(pendingAutomationRunNavigation.hostId, { + runtimeEnvironments + }) ) if (automationHostTargetKey !== pendingTargetKey) { void refresh() } - }, [automationHostTargetKey, isLoading, pendingAutomationRunNavigation, refresh]) + }, [ + automationHostTargetKey, + isLoading, + pendingAutomationRunNavigation, + refresh, + runtimeEnvironments + ]) useEffect(() => { for (const [workspaceId, worktree] of store.worktreeMap) { const displayName = worktree.displayName.trim()