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()