Allow automation deletion without SSH connection (#13261)

* Allow automation deletion without SSH connection

Remove external source entries from the list and only show jobs.
This allows users to delete and manage automations even when the
remote host is not connected.

* Update test: list jobs from unavailable automation manager

Remove availability constraints (status, error, canManage) from test to
verify jobs can be listed regardless of manager connection state.
This commit is contained in:
Jinjing 2026-08-08 13:50:04 -07:00 committed by GitHub
parent 04f7123d26
commit 8a061b9f04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 84 additions and 414 deletions

View File

@ -21,10 +21,7 @@ import {
getExternalTargetKindLabel
} from './external-automation-display'
import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display'
import {
getExternalAutomationActionDisabledMessage,
getExternalAutomationSourceAvailability
} from './external-automation-source-availability'
import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability'
import { translate } from '@/i18n/i18n'
export function AutomationListExternalRows({
@ -55,53 +52,6 @@ export function AutomationListExternalRows({
{entries.map((entry) => {
const providerLabel = getExternalProviderLabel(entry.manager)
const targetKindLabel = getExternalTargetKindLabel(entry.manager)
if (entry.kind === 'source') {
const sshStatus =
entry.manager.target.type === 'ssh'
? sshConnectionStates.get(entry.manager.target.connectionId)?.status
: undefined
const sourceAvailability = getExternalAutomationSourceAvailability({
manager: entry.manager,
providerLabel,
targetKindLabel,
sshStatus
})
return (
<button
key={entry.key}
type="button"
onClick={() => onSelect(entry.key)}
className={cn(
'mb-1 grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors',
selectedExternalKey === entry.key
? 'border-foreground/30 bg-muted/70 text-foreground shadow-sm'
: 'border-transparent hover:bg-muted/50'
)}
>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<span className="size-2 rounded-full bg-muted-foreground/40" />
<span className="truncate font-medium">{entry.manager.targetLabel}</span>
</span>
<span className="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<span>
{providerLabel}{' '}
{translate('auto.components.automations.AutomationsPage.82eb6cb933', 'source')}
</span>
<span className="shrink-0">/</span>
<span className="truncate">{targetKindLabel}</span>
</span>
<span className="mt-1 block truncate text-xs text-muted-foreground">
{sourceAvailability.summary}
</span>
</span>
<span className="flex max-w-28 flex-col items-end gap-1 text-right text-xs text-muted-foreground">
<Clock className="size-3.5" />
<span className="line-clamp-2">{sourceAvailability.statusLabel}</span>
</span>
</button>
)
}
const nextRunLabel = entry.job.enabled
? formatExternalDate(entry.job.nextRunAt, relativeNow)
: translate('auto.components.automations.AutomationsPage.paused', 'Paused')

View File

@ -36,7 +36,6 @@ import { getAutomationRunContent } from './automation-run-content'
import type { AutomationTargetAvailability } from './automation-target-availability'
import type { AutomationRunViewState } from './automation-run-view-state'
import type { AutomationRunWorkspaceDisplay } from './automation-run-workspace-display'
import type { ExternalAutomationSourceAvailability } from './external-automation-source-availability'
import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state'
import { translate } from '@/i18n/i18n'
@ -54,11 +53,6 @@ type AutomationsDetailPaneProps = {
selectedWorkspaceName: string
hostLabelById: ReadonlyMap<string, string>
selectedRunNowAvailability: AutomationTargetAvailability | null
selectedExternalSourceAvailability: ExternalAutomationSourceAvailability | null
selectedExternalSshSource: {
manager: ExternalAutomationManager
} | null
selectedExternalSshConnected: boolean
selectedAutomationRunPageWorkspaceDisplay: AutomationRunWorkspaceDisplay | null
selectedAutomationRunPageViewState: AutomationRunViewState | null
canRerunSelectedAutomationRunPage: boolean
@ -79,7 +73,6 @@ type AutomationsDetailPaneProps = {
run: ExternalAutomationRun
) => void
openEditExternalDialog: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void
connectExternalAutomationSource: (manager: ExternalAutomationManager) => void
runNow: (automation: Automation) => void
openEditDialog: (automation: Automation) => void
toggleAutomation: (automation: Automation) => void
@ -103,9 +96,6 @@ export function AutomationsDetailPane({
selectedWorkspaceName,
hostLabelById,
selectedRunNowAvailability,
selectedExternalSourceAvailability,
selectedExternalSshSource,
selectedExternalSshConnected,
selectedAutomationRunPageWorkspaceDisplay,
selectedAutomationRunPageViewState,
canRerunSelectedAutomationRunPage,
@ -118,7 +108,6 @@ export function AutomationsDetailPane({
requestExternalAction,
openExternalRunPage,
openEditExternalDialog,
connectExternalAutomationSource,
runNow,
openEditDialog,
toggleAutomation,
@ -146,7 +135,7 @@ export function AutomationsDetailPane({
>
<HermesCronOutputView content={getExternalRunContent(selectedExternalRunPage.run)} />
</AutomationRunPageFrame>
) : selectedExternal.kind === 'job' ? (
) : (
<ExternalAutomationManagers
managers={[
{
@ -161,51 +150,6 @@ export function AutomationsDetailPane({
onOpenRun={openExternalRunPage}
onEdit={openEditExternalDialog}
/>
) : (
<div className="rounded-md border border-border/50 bg-muted/20 shadow-sm">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{selectedExternal.manager.targetLabel}
</div>
<div className="text-xs text-muted-foreground">
{selectedExternalSourceAvailability?.summary}
</div>
</div>
{selectedExternalSshSource ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={selectedExternalSourceAvailability?.isConnecting ?? false}
onClick={() =>
void connectExternalAutomationSource(selectedExternalSshSource.manager)
}
>
{selectedExternalSourceAvailability?.isConnecting ? (
<RefreshCw className="size-3.5 animate-spin" />
) : null}
{selectedExternalSourceAvailability?.isConnecting
? translate(
'auto.components.automations.AutomationsPage.f93ed7a6f8',
'Connecting...'
)
: selectedExternalSshConnected
? translate(
'auto.components.automations.AutomationsPage.53f06f0ad5',
'Retry source'
)
: translate(
'auto.components.automations.AutomationsPage.7934ee0d81',
'Connect SSH'
)}
</Button>
) : null}
</div>
<div className="px-3 py-6 text-sm text-muted-foreground">
{selectedExternalSourceAvailability?.detail}
</div>
</div>
)}
</div>
) : (

View File

@ -74,10 +74,7 @@ import {
type RuntimeProviderPreflightStatus
} from '../task-source-provider-availability'
import type { TaskSourceHostAvailability } from '../task-source-context-summary'
import {
getExternalAutomationSourceAvailability,
isSshConnectionBusy
} from './external-automation-source-availability'
import {
createAutomationForTarget,
deleteAutomationForTarget,
@ -108,9 +105,6 @@ import {
import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state'
import {
getExternalAutomationKey,
getExternalAutomationSourceKey,
getExternalProviderLabel,
getExternalTargetKindLabel,
isMissingExternalRunsApiError
} from './external-automation-display'
import { buildExternalAutomationListEntries } from './external-automation-list-entries'
@ -208,9 +202,6 @@ export default function AutomationsPage(): React.JSX.Element {
setSelectedExternalRunPage(null)
setSelectedExternalKey(externalKey)
}, [])
const [connectingExternalSourceKey, setConnectingExternalSourceKey] = useState<string | null>(
null
)
const [draftAtOpen, setDraftAtOpen] = useState<AutomationDraft | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null)
const [externalDeleteTarget, setExternalDeleteTarget] = useState<{
@ -680,33 +671,6 @@ export default function AutomationsPage(): React.JSX.Element {
editingAutomationId === null ||
!draftAtOpen ||
JSON.stringify(draft) !== JSON.stringify(draftAtOpen)
const selectedExternalSshSource =
selectedExternal?.kind === 'source' && selectedExternal.manager.target.type === 'ssh'
? {
manager: selectedExternal.manager,
connectionId: selectedExternal.manager.target.connectionId,
sourceKey: getExternalAutomationSourceKey(selectedExternal.manager)
}
: null
const selectedExternalSshStatus = selectedExternalSshSource
? sshConnectionStates.get(selectedExternalSshSource.connectionId)?.status
: undefined
const selectedExternalSshConnected = selectedExternalSshStatus === 'connected'
const isSelectedExternalSshConnecting =
selectedExternalSshSource !== null &&
(connectingExternalSourceKey === selectedExternalSshSource.sourceKey ||
isSshConnectionBusy(selectedExternalSshStatus))
const selectedExternalSourceAvailability =
selectedExternal?.kind === 'source'
? getExternalAutomationSourceAvailability({
manager: selectedExternal.manager,
providerLabel: getExternalProviderLabel(selectedExternal.manager),
targetKindLabel: getExternalTargetKindLabel(selectedExternal.manager),
sshStatus: selectedExternalSshStatus,
isConnectingOverride: isSelectedExternalSshConnecting
})
: null
const getAutomationRepoHostLabel = useCallback(
(repo: Repo): string => {
const hostId = getRepoExecutionHostId(repo)
@ -1748,56 +1712,6 @@ export default function AutomationsPage(): React.JSX.Element {
await runExternalAction(target.manager, target.job, 'delete')
}
const connectExternalAutomationSource = async (
manager: ExternalAutomationManager
): Promise<void> => {
if (manager.target.type !== 'ssh') {
return
}
const sourceKey = getExternalAutomationSourceKey(manager)
setConnectingExternalSourceKey(sourceKey)
try {
if (sshConnectionStates.get(manager.target.connectionId)?.status === 'connected') {
await refresh()
toast.success(
translate(
'auto.components.automations.AutomationsPage.a21f6c33ad',
'Automation source refreshed.'
)
)
return
}
const state = await window.api.ssh.connect({
targetId: manager.target.connectionId
})
if (!state || state.status !== 'connected') {
toast.error(
state?.error ??
translate(
'auto.components.automations.AutomationsPage.7b2e285552',
'SSH connections are unavailable in this client.'
)
)
return
}
await refresh()
toast.success(
translate('auto.components.automations.AutomationsPage.9f2855677c', 'SSH connected.')
)
} catch (error) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.automations.AutomationsPage.3e42a5cc1b',
'SSH connection failed.'
)
)
} finally {
setConnectingExternalSourceKey(null)
}
}
const openRunWorkspace = (run: AutomationRun): void => {
const runWorktree = run.workspaceId ? (worktreeMap.get(run.workspaceId) ?? null) : null
const store = useAppStore.getState()
@ -2091,11 +2005,6 @@ export default function AutomationsPage(): React.JSX.Element {
}
hostLabelById={hostLabelById}
selectedRunNowAvailability={selectedRunNowAvailability}
selectedExternalSourceAvailability={selectedExternalSourceAvailability}
selectedExternalSshSource={
selectedExternalSshSource ? { manager: selectedExternalSshSource.manager } : null
}
selectedExternalSshConnected={selectedExternalSshConnected}
selectedAutomationRunPageWorkspaceDisplay={selectedAutomationRunPageWorkspaceDisplay}
selectedAutomationRunPageViewState={selectedAutomationRunPageViewState}
canRerunSelectedAutomationRunPage={canRerunSelectedAutomationRunPage}
@ -2108,9 +2017,6 @@ export default function AutomationsPage(): React.JSX.Element {
requestExternalAction={requestExternalAction}
openExternalRunPage={openExternalRunPage}
openEditExternalDialog={openEditExternalDialog}
connectExternalAutomationSource={(manager) =>
void connectExternalAutomationSource(manager)
}
runNow={(automation) => void runNow(automation)}
openEditDialog={(automation) => void openEditDialog(automation)}
toggleAutomation={(automation) => void toggleAutomation(automation)}

View File

@ -14,10 +14,6 @@ export function getExternalAutomationKey(
return `${manager.id}:${job.id}`
}
export function getExternalAutomationSourceKey(manager: ExternalAutomationManager): string {
return `${manager.id}:source`
}
export function formatExternalDate(value: string | null, now: number): string {
if (!value) {
return 'Never'

View File

@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import type { ExternalAutomationManager } from '../../../../shared/automations-types'
import { buildExternalAutomationListEntries } from './external-automation-list-entries'
function manager(overrides: Partial<ExternalAutomationManager> = {}): ExternalAutomationManager {
return {
id: 'hermes:ssh:openclaw',
provider: 'hermes',
label: 'Hermes on openclaw',
targetLabel: 'openclaw',
target: { type: 'ssh', connectionId: 'openclaw' },
status: 'unavailable',
error: 'SSH target is not connected.',
canManage: false,
jobs: [],
...overrides
}
}
describe('buildExternalAutomationListEntries', () => {
it('omits empty host probes', () => {
expect(buildExternalAutomationListEntries([manager()])).toEqual([])
})
it('lists jobs from an unavailable manager', () => {
expect(
buildExternalAutomationListEntries([
manager({
jobs: [
{
id: 'job-1',
managerId: 'hermes:ssh:openclaw',
provider: 'hermes',
name: 'Nightly',
schedule: '0 9 * * *',
rawSchedule: '0 9 * * *',
enabled: true,
state: 'active',
prompt: 'do work',
promptPreview: 'do work',
nextRunAt: null,
lastRunAt: null,
lastStatus: null,
lastError: null,
workdir: null,
runCount: 1,
runs: []
}
]
})
])
).toEqual([
expect.objectContaining({
job: expect.objectContaining({ id: 'job-1' })
})
])
})
})

View File

@ -2,45 +2,23 @@ import type {
ExternalAutomationJob,
ExternalAutomationManager
} from '../../../../shared/automations-types'
import {
getExternalAutomationKey,
getExternalAutomationSourceKey
} from './external-automation-display'
import { getExternalAutomationKey } from './external-automation-display'
export type ExternalAutomationListEntry =
| {
kind: 'job'
key: string
manager: ExternalAutomationManager
job: ExternalAutomationJob
}
| {
kind: 'source'
key: string
manager: ExternalAutomationManager
}
export type ExternalAutomationListEntry = {
key: string
manager: ExternalAutomationManager
job: ExternalAutomationJob
}
export function buildExternalAutomationListEntries(
managers: readonly ExternalAutomationManager[]
): ExternalAutomationListEntry[] {
return managers.flatMap((manager): ExternalAutomationListEntry[] => {
if (manager.jobs.length === 0) {
if (manager.provider === 'hermes' && (manager.status === 'unavailable' || manager.error)) {
return [
{
kind: 'source' as const,
key: getExternalAutomationSourceKey(manager),
manager
}
]
}
return []
}
return manager.jobs.map((job) => ({
kind: 'job' as const,
// Why: empty managers are host probes, not automations — omit them from the list.
return managers.flatMap((manager) =>
manager.jobs.map((job) => ({
key: getExternalAutomationKey(manager, job),
manager,
job
}))
})
)
}

View File

@ -1,9 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { ExternalAutomationManager } from '../../../../shared/automations-types'
import {
getExternalAutomationActionDisabledMessage,
getExternalAutomationSourceAvailability
} from './external-automation-source-availability'
import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability'
function manager(overrides: Partial<ExternalAutomationManager> = {}): ExternalAutomationManager {
return {
@ -20,86 +17,7 @@ function manager(overrides: Partial<ExternalAutomationManager> = {}): ExternalAu
}
}
describe('external automation source availability', () => {
it('uses local repair copy for unavailable local sources', () => {
expect(
getExternalAutomationSourceAvailability({
manager: manager(),
providerLabel: 'Hermes',
targetKindLabel: 'Local'
})
).toMatchObject({
statusLabel: 'Source unavailable',
summary: 'Hermes source unavailable on local.',
detail: 'Install or repair the local automation source, then retry to load jobs.',
canConnectSsh: false,
isConnecting: false
})
})
it('asks users to connect disconnected SSH sources before checking jobs', () => {
expect(
getExternalAutomationSourceAvailability({
manager: manager({
id: 'hermes-devbox',
targetLabel: 'Devbox',
target: { type: 'ssh', connectionId: 'devbox' }
}),
providerLabel: 'Hermes',
targetKindLabel: 'SSH host',
sshStatus: 'disconnected'
})
).toMatchObject({
statusLabel: 'Connect SSH',
summary: 'Hermes source unavailable until ssh host connects.',
detail: 'Connect this SSH host to check for remote automation jobs.',
canConnectSsh: true,
isConnecting: false
})
})
it('distinguishes connected SSH hosts with missing remote automation tooling', () => {
expect(
getExternalAutomationSourceAvailability({
manager: manager({
id: 'hermes-devbox',
targetLabel: 'Devbox',
target: { type: 'ssh', connectionId: 'devbox' }
}),
providerLabel: 'Hermes',
targetKindLabel: 'SSH host',
sshStatus: 'connected'
})
).toMatchObject({
statusLabel: 'Source unavailable',
summary: 'Hermes source unavailable on this ssh host.',
detail: 'Install or repair the remote automation source, then retry to load jobs.',
canConnectSsh: true,
isConnecting: false
})
})
it('preserves manager errors while still reporting a connecting SSH state', () => {
expect(
getExternalAutomationSourceAvailability({
manager: manager({
error: 'Hermes binary was not found.',
target: { type: 'ssh', connectionId: 'devbox' }
}),
providerLabel: 'Hermes',
targetKindLabel: 'SSH host',
sshStatus: 'connected',
isConnectingOverride: true
})
).toMatchObject({
statusLabel: 'Connecting...',
summary: 'Hermes binary was not found.',
detail: 'Waiting for this SSH host before checking the remote automation source.',
canConnectSsh: true,
isConnecting: true
})
})
describe('external automation action availability', () => {
it('explains disabled local automation actions when the source tool is missing', () => {
expect(
getExternalAutomationActionDisabledMessage({

View File

@ -5,76 +5,6 @@ import type {
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
export type ExternalAutomationSourceAvailability = {
statusLabel: string
summary: string
detail: string
canConnectSsh: boolean
isConnecting: boolean
}
type ExternalAutomationSourceAvailabilityArgs = {
manager: ExternalAutomationManager
providerLabel: string
targetKindLabel: string
sshStatus?: SshConnectionStatus
isConnectingOverride?: boolean
}
export function getExternalAutomationSourceAvailability({
manager,
providerLabel,
targetKindLabel,
sshStatus,
isConnectingOverride = false
}: ExternalAutomationSourceAvailabilityArgs): ExternalAutomationSourceAvailability {
if (manager.target.type === 'ssh') {
const isConnecting = isConnectingOverride || isSshConnectionBusy(sshStatus)
if (isConnecting) {
return {
statusLabel: 'Connecting...',
summary:
manager.error ??
`${providerLabel} source unavailable while ${targetKindLabel.toLowerCase()} connects.`,
detail: 'Waiting for this SSH host before checking the remote automation source.',
canConnectSsh: true,
isConnecting: true
}
}
if (sshStatus === 'connected') {
return {
statusLabel: 'Source unavailable',
summary:
manager.error ??
`${providerLabel} source unavailable on this ${targetKindLabel.toLowerCase()}.`,
detail: 'Install or repair the remote automation source, then retry to load jobs.',
canConnectSsh: true,
isConnecting: false
}
}
return {
statusLabel: 'Connect SSH',
summary:
manager.error ??
`${providerLabel} source unavailable until ${targetKindLabel.toLowerCase()} connects.`,
detail: 'Connect this SSH host to check for remote automation jobs.',
canConnectSsh: true,
isConnecting: false
}
}
return {
statusLabel: 'Source unavailable',
summary:
manager.error ?? `${providerLabel} source unavailable on ${targetKindLabel.toLowerCase()}.`,
detail: 'Install or repair the local automation source, then retry to load jobs.',
canConnectSsh: false,
isConnecting: false
}
}
export function isSshConnectionBusy(status: SshConnectionStatus | undefined): boolean {
return isConnectingSshStatus(status)
}

View File

@ -106,9 +106,6 @@ export function useAutomationListSearch({
() =>
externalAutomationEntries
.map((entry) => {
if (entry.kind === 'source') {
return `${entry.key}\u0001${entry.manager.targetLabel}\u0001${getExternalProviderLabel(entry.manager)}\u0001`
}
const prompt = truncateAutomationListSearchField(
entry.job.prompt ?? entry.job.promptPreview ?? '',
AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS
@ -123,24 +120,17 @@ export function useAutomationListSearch({
index: AutomationListSearchIndex
}[] => {
return externalAutomationEntries.map((entry) => {
const fields: AutomationListSearchFields =
entry.kind === 'source'
? {
name: entry.manager.targetLabel,
project: `${getExternalProviderLabel(entry.manager)} ${entry.manager.targetLabel}`,
prompt: ''
}
: {
name: entry.job.name,
project: [
getExternalProviderLabel(entry.manager),
entry.manager.targetLabel,
entry.job.workdir
]
.filter(Boolean)
.join(' '),
prompt: entry.job.prompt ?? entry.job.promptPreview ?? ''
}
const fields: AutomationListSearchFields = {
name: entry.job.name,
project: [
getExternalProviderLabel(entry.manager),
entry.manager.targetLabel,
entry.job.workdir
]
.filter(Boolean)
.join(' '),
prompt: entry.job.prompt ?? entry.job.promptPreview ?? ''
}
return { key: entry.key, index: buildAutomationListSearchIndex(fields) }
})
// eslint-disable-next-line react-hooks/exhaustive-deps -- fingerprint is the rebuild gate