mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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 <OS> + 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 <zwq19980411@gmail.com> * 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 <zwq19980411@gmail.com> * 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 <zwq19980411@gmail.com> * 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 <zwq19980411@gmail.com> --------- Co-authored-by: Chen <zwq19980411@gmail.com>
This commit is contained in:
@@ -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<string>
|
||||
/** 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 } : {})
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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])
|
||||
),
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -49,6 +49,36 @@ export type AutomationHostFilterResolutionInput = {
|
||||
referencedStableKeys?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,6 +114,10 @@ function Harness(): null {
|
||||
|
||||
async function renderCatalog(repos: readonly Repo[]): Promise<void> {
|
||||
mocks.state = storeState(repos)
|
||||
await renderCatalogWithState()
|
||||
}
|
||||
|
||||
async function renderCatalogWithState(): Promise<void> {
|
||||
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 })])
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user