From 8a061b9f047aa7534818fa38eae2a1c06c7fdcfc Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:50:04 -0700 Subject: [PATCH] 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. --- .../AutomationListExternalRows.tsx | 52 +--------- .../automations/AutomationsDetailPane.tsx | 58 +---------- .../automations/AutomationsPage.tsx | 96 +------------------ .../external-automation-display.ts | 4 - .../external-automation-list-entries.test.ts | 58 +++++++++++ .../external-automation-list-entries.ts | 42 ++------ ...nal-automation-source-availability.test.ts | 86 +---------------- ...external-automation-source-availability.ts | 70 -------------- .../automations/use-automation-list-search.ts | 32 +++---- 9 files changed, 84 insertions(+), 414 deletions(-) create mode 100644 src/renderer/src/components/automations/external-automation-list-entries.test.ts diff --git a/src/renderer/src/components/automations/AutomationListExternalRows.tsx b/src/renderer/src/components/automations/AutomationListExternalRows.tsx index b5269b655..e036dcef4 100644 --- a/src/renderer/src/components/automations/AutomationListExternalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListExternalRows.tsx @@ -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 ( - 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' - )} - > - - - - {entry.manager.targetLabel} - - - - {providerLabel}{' '} - {translate('auto.components.automations.AutomationsPage.82eb6cb933', 'source')} - - / - {targetKindLabel} - - - {sourceAvailability.summary} - - - - - {sourceAvailability.statusLabel} - - - ) - } const nextRunLabel = entry.job.enabled ? formatExternalDate(entry.job.nextRunAt, relativeNow) : translate('auto.components.automations.AutomationsPage.paused', 'Paused') diff --git a/src/renderer/src/components/automations/AutomationsDetailPane.tsx b/src/renderer/src/components/automations/AutomationsDetailPane.tsx index 2d290f26a..1864e0856 100644 --- a/src/renderer/src/components/automations/AutomationsDetailPane.tsx +++ b/src/renderer/src/components/automations/AutomationsDetailPane.tsx @@ -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 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({ > - ) : selectedExternal.kind === 'job' ? ( + ) : ( - ) : ( - - - - - {selectedExternal.manager.targetLabel} - - - {selectedExternalSourceAvailability?.summary} - - - {selectedExternalSshSource ? ( - - void connectExternalAutomationSource(selectedExternalSshSource.manager) - } - > - {selectedExternalSourceAvailability?.isConnecting ? ( - - ) : 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' - )} - - ) : null} - - - {selectedExternalSourceAvailability?.detail} - - )} ) : ( diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index e8a33f4dc..7293b79ad 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -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( - null - ) const [draftAtOpen, setDraftAtOpen] = useState(null) const [deleteTarget, setDeleteTarget] = useState(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 => { - 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)} diff --git a/src/renderer/src/components/automations/external-automation-display.ts b/src/renderer/src/components/automations/external-automation-display.ts index ee8e9d19d..beb7fc200 100644 --- a/src/renderer/src/components/automations/external-automation-display.ts +++ b/src/renderer/src/components/automations/external-automation-display.ts @@ -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' diff --git a/src/renderer/src/components/automations/external-automation-list-entries.test.ts b/src/renderer/src/components/automations/external-automation-list-entries.test.ts new file mode 100644 index 000000000..8db60dd1c --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-list-entries.test.ts @@ -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 { + 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' }) + }) + ]) + }) +}) diff --git a/src/renderer/src/components/automations/external-automation-list-entries.ts b/src/renderer/src/components/automations/external-automation-list-entries.ts index 01b3a8472..5e8968413 100644 --- a/src/renderer/src/components/automations/external-automation-list-entries.ts +++ b/src/renderer/src/components/automations/external-automation-list-entries.ts @@ -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 })) - }) + ) } diff --git a/src/renderer/src/components/automations/external-automation-source-availability.test.ts b/src/renderer/src/components/automations/external-automation-source-availability.test.ts index 37f895ea6..34dd5e7be 100644 --- a/src/renderer/src/components/automations/external-automation-source-availability.test.ts +++ b/src/renderer/src/components/automations/external-automation-source-availability.test.ts @@ -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 { return { @@ -20,86 +17,7 @@ function manager(overrides: Partial = {}): 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({ diff --git a/src/renderer/src/components/automations/external-automation-source-availability.ts b/src/renderer/src/components/automations/external-automation-source-availability.ts index acb51056e..53898b843 100644 --- a/src/renderer/src/components/automations/external-automation-source-availability.ts +++ b/src/renderer/src/components/automations/external-automation-source-availability.ts @@ -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) } diff --git a/src/renderer/src/components/automations/use-automation-list-search.ts b/src/renderer/src/components/automations/use-automation-list-search.ts index ce16d98c0..235c08cdb 100644 --- a/src/renderer/src/components/automations/use-automation-list-search.ts +++ b/src/renderer/src/components/automations/use-automation-list-search.ts @@ -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