Filter automation create projects by destination host

Only offer projects available on the selected destination, preventing
the mismatches that would fail at submit time. Auto-adjust the project
selection if it becomes unavailable when the destination changes.
This commit is contained in:
Jinjing
2026-08-23 15:43:12 -07:00
parent 02775968ff
commit 8fa6735b9c
8 changed files with 264 additions and 19 deletions
@@ -0,0 +1,86 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AutomationCreateDestinationField } from './AutomationCreateDestinationField'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref'
import type { AutomationCreateDestinationControl } from './use-automation-create-destination'
import type { Repo } from '../../../../shared/types'
let container: HTMLDivElement
let root: Root
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
const OWNER: AutomationOwnerRef = {
authority: { kind: 'desktop' },
selector: { kind: 'ssh', targetId: 't1', targetGeneration: 1 }
}
const ENTRY: AutomationHostCatalogEntry = {
stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } },
owner: OWNER,
stableKey: 'host:desktop:ssh:t1',
label: 'openclaw',
authorityLabel: 'Local Mac',
kind: 'ssh',
catalogState: 'authoritative',
authorityHealth: 'fresh',
executionHealth: 'connected',
querySupport: 'scoped'
}
function control(projects: Repo[]): AutomationCreateDestinationControl {
return {
entries: [ENTRY],
resolution: {
status: 'ready',
authority: OWNER.authority,
destination: { selector: OWNER.selector },
entry: ENTRY
},
onSelect: () => undefined,
projects
}
}
function render(projects: Repo[]): void {
act(() => {
root.render(
<TooltipProvider>
<AutomationCreateDestinationField control={control(projects)} />
</TooltipProvider>
)
})
}
function emptyNote(): HTMLElement | null {
return container.querySelector('[data-testid="automation-create-no-projects"]')
}
describe('AutomationCreateDestinationField', () => {
it('names the host that has no projects rather than leaving Create dead', () => {
render([])
expect(emptyNote()?.textContent).toContain('openclaw')
})
it('states the storing authority once the host has a project to offer', () => {
render([{ id: 'repo-1' } as Repo])
expect(emptyNote()).toBeNull()
expect(container.textContent).toContain('Local Mac')
})
})
@@ -67,17 +67,27 @@ export function AutomationCreateDestinationField({
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{selected
? translate(
'auto.components.automations.createDestination.storedOn',
'Stored and scheduled by {authority}.'
).replace('{authority}', selected.authorityLabel)
: translate(
'auto.components.automations.createDestination.unselected',
'Choose the host this automation will be created on.'
)}
</p>
{selected && control.projects.length === 0 ? (
// Otherwise Create is disabled for an empty project list with nothing said.
<p className="text-xs text-destructive" data-testid="automation-create-no-projects">
{translate(
'auto.components.automations.createDestination.noProjects',
'No projects are set up on {host}. Add one there, or choose another host.'
).replace('{host}', selected.label)}
</p>
) : (
<p className="text-xs text-muted-foreground">
{selected
? translate(
'auto.components.automations.createDestination.storedOn',
'Stored and scheduled by {authority}.'
).replace('{authority}', selected.authorityLabel)
: translate(
'auto.components.automations.createDestination.unselected',
'Choose the host this automation will be created on.'
)}
</p>
)}
</Field>
)
}
@@ -36,6 +36,12 @@ installAutomationsPageHarness()
const RUNTIME_REPO_ID = 'repo-2'
const RUNTIME_WORKSPACE_ID = 'workspace-2'
const SSH_TARGET_ID = 'ssh-target-1'
const SSH_REPO_ID = 'repo-ssh'
const SSH_HOST_KEY = hostStableKey({
authority: { kind: 'desktop' },
selector: { kind: 'ssh', targetId: SSH_TARGET_ID }
})
const DESKTOP_SELF_KEY = hostStableKey({
authority: { kind: 'desktop' },
@@ -90,6 +96,28 @@ function addRuntimeProject(): void {
mocks.worktreeMap.set(RUNTIME_WORKSPACE_ID, worktree)
}
/** A desktop-registered SSH host, with the generation that makes it fenceable. */
function addSshHost(): void {
mocks.state.sshTargetLabels = new Map([[SSH_TARGET_ID, 'openclaw']])
mocks.state.sshTargetGenerations = new Map([[SSH_TARGET_ID, 1]])
mocks.state.sshConnectionStates = new Map([[SSH_TARGET_ID, { status: 'connected' }]])
}
/** A project checked out on that SSH host rather than locally. */
function addSshProject(): void {
const repo = {
id: SSH_REPO_ID,
displayName: 'orca',
path: '/repos/orca',
badgeColor: '#222222',
addedAt: 1,
worktreeBaseRef: 'main',
connectionId: SSH_TARGET_ID
} as Repo
mocks.state.repos = [...(mocks.state.repos as Repo[]), repo]
mocks.repoMap.set(SSH_REPO_ID, repo)
}
/** The runtime answers a create; without this the RPC double returns an empty result. */
function runtimeCreateReturns(automation: Automation): void {
const previous = mocks.callRuntimeRpc.getMockImplementation()
@@ -195,6 +223,64 @@ describe('AutomationsPage create destination', () => {
expect(api.automations.create).not.toHaveBeenCalled()
})
it('offers only the projects the chosen host actually has', async () => {
api.automations.list.mockResolvedValue([])
scopedList([])
addSshHost()
addSshProject()
await renderPage()
await settleHostQueries()
await act(async () => {
mocks.listPanel?.openCreateDialog()
})
await act(async () => {
mocks.editorDialog?.createDestination?.onSelect(SSH_HOST_KEY)
})
// The local project shares this one's name, so offering both leaves the user
// no way to tell which is the one on that host.
expect(mocks.editorDialog?.repos?.map((repo) => repo.id)).toEqual([SSH_REPO_ID])
})
it('moves a stranded project to one the newly chosen host has', async () => {
api.automations.list.mockResolvedValue([])
scopedList([])
addSshHost()
addSshProject()
await renderPage()
await settleHostQueries()
await openCreateDialogFor(REPO_ID, WORKSPACE_ID)
expect(mocks.editorDialog?.draft?.projectId).toBe(REPO_ID)
await act(async () => {
mocks.editorDialog?.createDestination?.onSelect(SSH_HOST_KEY)
})
// Keeping the local project selected only defers the same refusal to submit.
expect(mocks.editorDialog?.draft?.projectId).toBe(SSH_REPO_ID)
})
it('offers nothing rather than a stranded project when the host has no projects', async () => {
api.automations.list.mockResolvedValue([])
scopedList([])
addSshHost()
await renderPage()
await settleHostQueries()
await act(async () => {
mocks.listPanel?.openCreateDialog()
})
await act(async () => {
mocks.editorDialog?.createDestination?.onSelect(SSH_HOST_KEY)
})
expect(mocks.editorDialog?.repos).toEqual([])
// Nothing is left selected to submit against a host that cannot hold it.
expect(mocks.editorDialog?.draft?.projectId).toBe('')
})
it('states the chosen host on the form before submit', async () => {
api.automations.list.mockResolvedValue([])
scopedList([])
@@ -578,8 +578,22 @@ export default function AutomationsPage(): React.JSX.Element {
// Non-null only for a concrete host filter, which constrains the destination.
filterStableKey: hostCatalog.resolution.entry?.stableKey ?? null,
activeWorkspaceStableKey: activeWorkspaceHostStableKey,
repoTables
repoTables,
projects: repos
})
const editorProjects = createDestination.control.projects
// A destination change can strand the chosen project on another host; leaving it
// selected only defers the same refusal to submit.
useEffect(() => {
if (!createOpen || editingAutomationId !== null || createTarget !== 'orca') {
return
}
setDraft((current) =>
!current.projectId || editorProjects.some((project) => project.id === current.projectId)
? current
: { ...current, projectId: '', workspaceId: '', baseBranch: '' }
)
}, [createOpen, createTarget, editingAutomationId, editorProjects])
// The row's own host, from its captured owner. A page-level target cannot
// speak for a list spanning authorities, and the legacy arm below it is only
// ever reached by rows the desktop's unscoped list produced.
@@ -848,17 +862,25 @@ export default function AutomationsPage(): React.JSX.Element {
const getDefaultTarget = useCallback(() => {
const activeWorktree = activeWorktreeId ? worktreeMap.get(activeWorktreeId) : null
const activeRepo = activeWorktree ? (repoMap.get(activeWorktree.repoId) ?? null) : null
const fallbackRepo = activeRepo ?? repos[0] ?? null
// The stated destination decides which projects exist for this draft, so an
// active workspace on another host is not a candidate here.
const eligibleActiveRepo =
activeRepo && editorProjects.some((project) => project.id === activeRepo.id)
? activeRepo
: null
const fallbackRepo = eligibleActiveRepo ?? editorProjects[0] ?? null
const fallbackWorktrees = fallbackRepo ? (worktreesByRepo[fallbackRepo.id] ?? []) : []
// Why: automation-created workspaces can be active; new automations should start from
// the repo's stable main worktree unless the user explicitly chooses otherwise.
const targetWorktree = getDefaultWorktree(fallbackWorktrees) ?? activeWorktree
const targetWorktree =
getDefaultWorktree(fallbackWorktrees) ??
(activeWorktree && activeWorktree.repoId === fallbackRepo?.id ? activeWorktree : null)
const targetProjectId = fallbackRepo?.id ?? targetWorktree?.repoId ?? ''
return {
projectId: targetProjectId,
workspaceId: targetWorktree?.id ?? ''
}
}, [activeWorktreeId, repoMap, repos, worktreeMap, worktreesByRepo])
}, [activeWorktreeId, editorProjects, repoMap, worktreeMap, worktreesByRepo])
const reloadExternalManagers = scopedExternal.reload
@@ -1951,7 +1973,7 @@ export default function AutomationsPage(): React.JSX.Element {
canSave={canSaveDraft}
isEditingExternal={editingExternalTarget !== null}
createTarget={createTarget}
repos={repos}
repos={editorProjects}
projectHostSetups={projectHostSetups}
automationYamlHooksByRepoKey={automationYamlHooksByRepoKey}
getAutomationHooksCacheKey={getAutomationHooksCacheKey}
@@ -162,6 +162,20 @@ export function automationCreateProjectMismatch(
return selector.kind === 'ssh' ? connectionId !== selector.targetId : connectionId !== null
}
/**
* The projects a destination can hold, filtered by the rule submit already
* enforces, so the form cannot offer a pairing its own check will refuse.
*/
export function automationCreateEligibleProjects<TProject extends { id: string }>(
tables: AutomationAuthorityRepoTables,
destination: AutomationCreateDestination,
projects: readonly TProject[]
): TProject[] {
return projects.filter(
(project) => !automationCreateProjectMismatch(tables, destination, project.id)
)
}
function destinationOwner(value: AutomationCreateDestination): AutomationOwnerRef {
return { authority: value.authority, selector: value.destination.selector }
}
@@ -87,6 +87,8 @@ export type EditorDialogProps = {
isEditing: boolean
createDestination?: AutomationCreateDestinationControl
notice?: { message: string } | null
repos?: { id: string }[]
draft?: { projectId: string; workspaceId: string }
onSave: () => void
onDraftChange: (updater: (current: unknown) => unknown) => void
}
@@ -10,12 +10,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { translate } from '@/i18n/i18n'
import type { Repo } from '../../../../shared/types'
import type {
AutomationHostCatalog,
AutomationHostCatalogEntry
} from './automation-host-catalog-types'
import type { AutomationAuthorityRepoTables } from './automation-authority-identity'
import {
automationCreateEligibleProjects,
automationCreateProjectMismatch,
preselectAutomationCreateHost,
resolveAutomationCreateDestination,
@@ -31,6 +33,8 @@ export type AutomationCreateDestinationControl = {
entries: readonly AutomationHostCatalogEntry[]
resolution: AutomationCreateDestinationResolution
onSelect: (stableKey: string) => void
/** Projects the resolved destination can hold; the whole list until one resolves. */
projects: readonly Repo[]
}
export type AutomationCreateDestinationCheck =
@@ -46,6 +50,7 @@ export type AutomationCreateDestinationInput = {
filterStableKey: string | null
activeWorkspaceStableKey: string | null
repoTables: AutomationAuthorityRepoTables
projects: readonly Repo[]
}
export type AutomationCreateDestinationState = {
@@ -79,7 +84,15 @@ function ownerNotice(message: string): AutomationActionNotice {
export function useAutomationCreateDestination(
input: AutomationCreateDestinationInput
): AutomationCreateDestinationState {
const { open, catalog, entries, filterStableKey, activeWorkspaceStableKey, repoTables } = input
const {
open,
catalog,
entries,
filterStableKey,
activeWorkspaceStableKey,
repoTables,
projects
} = input
const [captured, setCaptured] = useState<AutomationCreateDestination | null>(null)
const [reason, setReason] = useState<AutomationCreateDestinationChoiceReason>('unselected')
@@ -169,8 +182,19 @@ export function useAutomationCreateDestination(
[captured, reason]
)
const eligibleProjects = useMemo(
() =>
resolution.status === 'ready'
? automationCreateEligibleProjects(repoTables, resolution, projects)
: projects,
[projects, repoTables, resolution]
)
return {
control: useMemo(() => ({ entries, resolution, onSelect }), [entries, onSelect, resolution]),
control: useMemo(
() => ({ entries, resolution, onSelect, projects: eligibleProjects }),
[eligibleProjects, entries, onSelect, resolution]
),
check
}
}
+2 -1
View File
@@ -15495,7 +15495,8 @@
"orphan": "Automations with no host cannot hold new ones. Choose a host to create this automation on.",
"unavailable": "That host cannot hold a new automation yet. Choose another host to create this one on.",
"stale": "{host} changed while this form was open. Choose it again before saving.",
"projectMismatch": "That project is not on {host}. Choose a project on that host, or another host."
"projectMismatch": "That project is not on {host}. Choose a project on that host, or another host.",
"noProjects": "No projects are set up on {host}. Add one there, or choose another host."
}
},
"agent": {