mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user