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:
parent
04f7123d26
commit
8a061b9f04
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}))
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue