Gate unavailable project host setup options

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 12:34:00 -07:00
co-authored by Orca
parent 4bb67665b5
commit 79ff2475c1
4 changed files with 141 additions and 11 deletions
@@ -21,14 +21,21 @@ vi.mock('@/components/ui/command', () => ({
CommandList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CommandItem: ({
children,
disabled,
onSelect,
value
}: {
children: React.ReactNode
disabled?: boolean
onSelect?: (value: string) => void
value: string
}) => (
<button type="button" data-command-value={value} onClick={() => onSelect?.(value)}>
<button
type="button"
data-command-value={value}
disabled={disabled}
onClick={() => onSelect?.(value)}
>
{children}
</button>
)
@@ -54,7 +61,18 @@ const needsSetupOption: NeedsSetupProjectHostOption = {
projectId: 'project-1',
hostId: 'ssh:builder',
label: 'Builder',
detail: 'Project not set up on this host'
detail: 'Project not set up on this host',
isAvailable: true
}
const unavailableOption: NeedsSetupProjectHostOption = {
id: 'needs-setup:runtime:old',
kind: 'needs-setup',
projectId: 'project-1',
hostId: 'runtime:old',
label: 'Old server',
detail: 'Update Orca on this host to set up projects',
isAvailable: false
}
beforeEach(() => {
@@ -121,4 +139,33 @@ describe('ProjectHostSetupCombobox', () => {
expect(onValueChange).not.toHaveBeenCalled()
expect(onNeedsSetupHostSelect).toHaveBeenCalledWith(needsSetupOption)
})
it('keeps unavailable setup rows visible but not selectable', () => {
const onValueChange = vi.fn()
const onNeedsSetupHostSelect = vi.fn()
act(() => {
root.render(
<ProjectHostSetupCombobox
options={[readyOption, unavailableOption]}
value={readyOption.id}
onValueChange={onValueChange}
onNeedsSetupHostSelect={onNeedsSetupHostSelect}
/>
)
})
const unavailableButton = container.querySelector<HTMLButtonElement>(
'[data-command-value="needs-setup:runtime:old"]'
)
expect(unavailableButton?.textContent).toContain('Update Orca on this host')
expect(unavailableButton?.disabled).toBe(true)
act(() => {
unavailableButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onValueChange).not.toHaveBeenCalled()
expect(onNeedsSetupHostSelect).not.toHaveBeenCalled()
})
})
@@ -34,6 +34,9 @@ export default function ProjectHostSetupCombobox({
return
}
if (option.kind === 'needs-setup') {
if (!option.isAvailable) {
return
}
onNeedsSetupHostSelect?.(option)
setOpen(false)
return
@@ -90,6 +93,7 @@ export default function ProjectHostSetupCombobox({
key={option.id}
value={option.id}
onSelect={() => handleSelect(option.id)}
disabled={option.kind === 'needs-setup' && !option.isAvailable}
className="items-center gap-2 px-3 py-2"
>
<Check
@@ -96,7 +96,8 @@ describe('buildProjectHostSetupOptions', () => {
id: 'needs-setup:ssh:builder',
kind: 'needs-setup',
label: 'Builder',
detail: 'Project not set up on this host'
detail: 'Project not set up on this host',
isAvailable: true
})
])
})
@@ -105,7 +106,10 @@ describe('buildProjectHostSetupOptions', () => {
const options = buildProjectHostSetupOptions({
projectId: 'project-1',
eligibleRepos: [repo('local-repo')],
hosts: [host('local'), host('runtime:gpu', { label: 'GPU VM' })],
hosts: [
host('local'),
host('runtime:gpu', { label: 'GPU VM', capabilities: ['project-host-setup.v1'] })
],
projectHostSetups: [
setup('local', 'project-1', 'local', 'local-repo'),
setup('gpu-pending', 'project-1', 'runtime:gpu', '', {
@@ -122,7 +126,8 @@ describe('buildProjectHostSetupOptions', () => {
id: 'needs-setup:runtime:gpu',
kind: 'needs-setup',
label: 'GPU VM',
detail: 'Project setup is in progress'
detail: 'Project setup is in progress',
isAvailable: true
})
])
})
@@ -137,7 +142,7 @@ describe('buildProjectHostSetupOptions', () => {
expect(
buildProjectHostSetupOptions({
...base,
hosts: [host('runtime:gpu', { label: 'GPU VM' })],
hosts: [host('runtime:gpu', { label: 'GPU VM', capabilities: ['project-host-setup.v1'] })],
projectHostSetups: [
...base.projectHostSetups,
setup('gpu-pending', 'project-1', 'runtime:gpu', '', {
@@ -152,7 +157,7 @@ describe('buildProjectHostSetupOptions', () => {
expect(
buildProjectHostSetupOptions({
...base,
hosts: [host('runtime:gpu', { label: 'GPU VM' })],
hosts: [host('runtime:gpu', { label: 'GPU VM', capabilities: ['project-host-setup.v1'] })],
projectHostSetups: [
...base.projectHostSetups,
setup('gpu-pending', 'project-1', 'runtime:gpu', '', {
@@ -167,7 +172,7 @@ describe('buildProjectHostSetupOptions', () => {
expect(
buildProjectHostSetupOptions({
...base,
hosts: [host('runtime:gpu', { label: 'GPU VM' })],
hosts: [host('runtime:gpu', { label: 'GPU VM', capabilities: ['project-host-setup.v1'] })],
projectHostSetups: [
...base.projectHostSetups,
setup('gpu-pending', 'project-1', 'runtime:gpu', '', {
@@ -179,4 +184,47 @@ describe('buildProjectHostSetupOptions', () => {
}).at(-1)
).toMatchObject({ detail: 'Project is unsupported on this host' })
})
it('marks incompatible runtime hosts as visible but unavailable', () => {
const options = buildProjectHostSetupOptions({
projectId: 'project-1',
eligibleRepos: [repo('local-repo')],
hosts: [
host('local'),
host('runtime:gpu', {
label: 'GPU VM',
health: 'blocked',
capabilities: ['project-host-setup.v1']
})
],
projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')]
})
expect(options).toEqual([
expect.objectContaining({ id: 'local', kind: 'ready', label: 'Local Mac' }),
expect.objectContaining({
id: 'needs-setup:runtime:gpu',
kind: 'needs-setup',
label: 'GPU VM',
detail: 'Orca server version is incompatible',
isAvailable: false
})
])
})
it('marks runtime hosts without project setup capability as unavailable', () => {
const options = buildProjectHostSetupOptions({
projectId: 'project-1',
eligibleRepos: [repo('local-repo')],
hosts: [host('local'), host('runtime:gpu', { label: 'GPU VM', capabilities: [] })],
projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')]
})
expect(options.at(-1)).toMatchObject({
id: 'needs-setup:runtime:gpu',
kind: 'needs-setup',
detail: 'Update Orca on this host to set up projects',
isAvailable: false
})
})
})
@@ -4,6 +4,7 @@ import {
type ExecutionHostId
} from '../../../shared/execution-host'
import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry'
import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import type { ProjectHostSetup, Repo } from '../../../shared/types'
export type ProjectHostSetupOption =
@@ -24,6 +25,7 @@ export type ProjectHostSetupOption =
hostId: ExecutionHostId
label: string
detail: string
isAvailable: boolean
}
export type ReadyProjectHostSetupOption = Extract<ProjectHostSetupOption, { kind: 'ready' }>
@@ -124,19 +126,48 @@ function buildNeedsSetupOptions({
.filter((host) => !readySetupByHost.has(host.id))
.map((host) => {
const pendingSetup = pendingSetupByHost.get(host.id)
const availability = getHostSetupAvailability(host)
return {
id: `needs-setup:${host.id}`,
kind: 'needs-setup' as const,
projectId,
hostId: host.id,
label: host.label || getExecutionHostLabel(host.id),
detail: pendingSetup
? getPendingSetupDetail(pendingSetup)
: 'Project not set up on this host'
detail: availability.isAvailable
? pendingSetup
? getPendingSetupDetail(pendingSetup)
: 'Project not set up on this host'
: availability.detail,
isAvailable: availability.isAvailable
}
})
}
function getHostSetupAvailability(host: ExecutionHostRegistryEntry): {
isAvailable: boolean
detail: string
} {
if (host.health === 'blocked') {
return {
isAvailable: false,
detail: 'Orca server version is incompatible'
}
}
if (
host.kind === 'runtime' &&
!host.capabilities?.includes(PROJECT_HOST_SETUP_RUNTIME_CAPABILITY)
) {
return {
isAvailable: false,
detail: 'Update Orca on this host to set up projects'
}
}
return {
isAvailable: true,
detail: ''
}
}
function getPendingSetupDetail(setup: ProjectHostSetup): string {
switch (setup.setupState) {
case 'not-set-up':