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 ( - - ) - } 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 ? ( - - ) : 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