Explain disabled external automation actions

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-12 23:31:44 -07:00
co-authored by Orca
parent 7db57daa14
commit 8feae89bff
5 changed files with 174 additions and 25 deletions
@@ -190,6 +190,7 @@ Done means: reviewers and future agents can understand the vision, implementatio
- [x] 2026-06-12: Added repo-backed Tasks empty-state copy that distinguishes missing project sources from no matching GitHub/GitLab work, then verified with focused empty-state tests and web typecheck.
- [x] 2026-06-12: Scoped Jira Tasks drawer/list cache reconciliation by Jira task source context and site id so same-key issues from different hosts/accounts cannot replace each other; verified with focused cache selector tests, targeted oxlint, and full typecheck.
- [x] 2026-06-12: Seeded New Workspace run-target selection from the incoming task source context so Linear/Jira starts preselect the logical project/source host instead of the ambient active repo; verified with a focused composer host-boundary test, targeted oxlint, and full typecheck.
- [x] 2026-06-12: Added explicit disabled-reason copy for external automation actions when an SSH host is disconnected/connecting, another action is in flight, or the local/remote automation source tool is missing; verified with focused availability tests, targeted oxlint, git diff --check, and full typecheck.
- [x] 2026-06-12: Fed Tasks source-context diagnostics from the shared execution-host registry so the source chip now reports SSH availability and blocked/incompatible remote-server hosts; verified with summary tests and web typecheck.
- [x] 2026-06-12: Extended Linear/Jira Tasks source diagnostics to include the account-owning host plus blocked remote-server availability, keeping account source separate from workspace run host; verified with summary tests and web typecheck.
- [x] 2026-06-12: Started the user-facing Host copy audit by replacing stale “remote project” and “Remote SSH” UI labels with project-on-host / SSH-host language; verified with web typecheck.
@@ -90,6 +90,7 @@ import { AutomationRunHistory } from './AutomationRunHistory'
import { getAutomationTemplates, type AutomationTemplate } from './automation-templates'
import { getAutomationTargetAvailability } from './automation-target-availability'
import {
getExternalAutomationActionDisabledMessage,
getExternalAutomationSourceAvailability,
isSshConnectionBusy
} from './external-automation-source-availability'
@@ -2006,7 +2007,18 @@ export default function AutomationsPage(): React.JSX.Element {
const nextRunLabel = entry.job.enabled
? formatExternalDate(entry.job.nextRunAt, relativeNow)
: 'Paused'
const actionDisabled = !entry.manager.canManage || externalActionKey !== null
const entrySshStatus =
entry.manager.target.type === 'ssh'
? sshConnectionStates.get(entry.manager.target.connectionId)?.status
: undefined
const disabledMessage = getExternalAutomationActionDisabledMessage({
manager: entry.manager,
providerLabel,
targetKindLabel,
sshStatus: entrySshStatus,
actionInProgress: externalActionKey !== null
})
const actionDisabled = disabledMessage !== null
const scheduleDisplay = getExternalAutomationScheduleDisplay(entry.manager, entry.job)
return (
<ContextMenu key={entry.key}>
@@ -2069,10 +2081,13 @@ export default function AutomationsPage(): React.JSX.Element {
onSelect={() => requestExternalAction(entry.manager, entry.job, 'run')}
>
<Play className="size-3.5" />
{translate(
'auto.components.automations.AutomationsPage.2faecab10b',
'Run Now'
)}
<span className="min-w-0 truncate">
{disabledMessage ??
translate(
'auto.components.automations.AutomationsPage.2faecab10b',
'Run Now'
)}
</span>
</ContextMenuItem>
{entry.manager.provider === 'hermes' ? (
<ContextMenuItem
@@ -15,6 +15,7 @@ import {
type FetchExternalAutomationRuns
} from './ExternalAutomationRunTable'
import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display'
import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability'
import { translate } from '@/i18n/i18n'
type ExternalAutomationManagersProps = {
@@ -163,6 +164,10 @@ export function ExternalAutomationManagers({
<div className="divide-y divide-border/40">
{manager.jobs.map((job) => {
const scheduleDisplay = getExternalAutomationScheduleDisplay(manager, job)
const disabledMessage = getExternalAutomationActionDisabledMessage({
manager,
actionInProgress: runningActionKey !== null
})
return (
<div
key={job.id}
@@ -228,11 +233,14 @@ export function ExternalAutomationManagers({
</div>
<div className="flex items-center justify-end gap-1">
<ExternalActionButton
label={translate(
'auto.components.automations.ExternalAutomationManagers.cc77ba88ff',
'Run external automation'
)}
disabled={!manager.canManage || runningActionKey !== null}
label={
disabledMessage ??
translate(
'auto.components.automations.ExternalAutomationManagers.cc77ba88ff',
'Run external automation'
)
}
disabled={disabledMessage !== null}
onClick={() => onAction(manager, job, 'run')}
>
{runningActionKey === actionKey(manager, job, 'run') ? (
@@ -243,11 +251,14 @@ export function ExternalAutomationManagers({
</ExternalActionButton>
{manager.provider === 'hermes' ? (
<ExternalActionButton
label={translate(
'auto.components.automations.ExternalAutomationManagers.1df491fd00',
'Edit external automation'
)}
disabled={!manager.canManage || runningActionKey !== null}
label={
disabledMessage ??
translate(
'auto.components.automations.ExternalAutomationManagers.1df491fd00',
'Edit external automation'
)
}
disabled={disabledMessage !== null}
onClick={() => onEdit?.(manager, job)}
>
<Pencil className="size-3.5" />
@@ -255,7 +266,8 @@ export function ExternalAutomationManagers({
) : null}
<ExternalActionButton
label={
job.enabled
disabledMessage ??
(job.enabled
? translate(
'auto.components.automations.ExternalAutomationManagers.0def1693bb',
'Pause external automation'
@@ -263,9 +275,9 @@ export function ExternalAutomationManagers({
: translate(
'auto.components.automations.ExternalAutomationManagers.1c3bfd38fe',
'Resume external automation'
)
))
}
disabled={!manager.canManage || runningActionKey !== null}
disabled={disabledMessage !== null}
onClick={() => onAction(manager, job, job.enabled ? 'pause' : 'resume')}
>
{runningActionKey ===
@@ -278,12 +290,15 @@ export function ExternalAutomationManagers({
)}
</ExternalActionButton>
<ExternalActionButton
label={translate(
'auto.components.automations.ExternalAutomationManagers.a42bf2b27e',
'Delete external automation'
)}
label={
disabledMessage ??
translate(
'auto.components.automations.ExternalAutomationManagers.a42bf2b27e',
'Delete external automation'
)
}
className="text-destructive hover:text-destructive"
disabled={!manager.canManage || runningActionKey !== null}
disabled={disabledMessage !== null}
onClick={() => onAction(manager, job, 'delete')}
>
{runningActionKey === actionKey(manager, job, 'delete') ? (
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import type { ExternalAutomationManager } from '../../../../shared/automations-types'
import { getExternalAutomationSourceAvailability } from './external-automation-source-availability'
import {
getExternalAutomationActionDisabledMessage,
getExternalAutomationSourceAvailability
} from './external-automation-source-availability'
function manager(overrides: Partial<ExternalAutomationManager> = {}): ExternalAutomationManager {
return {
@@ -96,4 +99,71 @@ describe('external automation source availability', () => {
isConnecting: true
})
})
it('explains disabled local automation actions when the source tool is missing', () => {
expect(
getExternalAutomationActionDisabledMessage({
manager: manager({ error: 'Hermes jobs were found, but the hermes CLI is not on PATH.' })
})
).toBe('Hermes jobs were found, but the hermes CLI is not on PATH.')
})
it('explains disabled SSH automation actions before the host is connected', () => {
expect(
getExternalAutomationActionDisabledMessage({
manager: manager({
target: { type: 'ssh', connectionId: 'devbox' },
error: 'SSH target is not connected.'
}),
providerLabel: 'Hermes',
targetKindLabel: 'SSH host',
sshStatus: 'disconnected'
})
).toBe('Connect this ssh host before managing Hermes automations.')
})
it('explains disabled SSH automation actions while the host is connecting', () => {
expect(
getExternalAutomationActionDisabledMessage({
manager: manager({
target: { type: 'ssh', connectionId: 'devbox' },
error: 'SSH target is not connected.'
}),
targetKindLabel: 'SSH host',
sshStatus: 'deploying-relay'
})
).toBe('Wait for this ssh host to finish connecting.')
})
it('explains disabled SSH automation actions when the remote source tool is missing', () => {
expect(
getExternalAutomationActionDisabledMessage({
manager: manager({
target: { type: 'ssh', connectionId: 'devbox' },
error: 'Hermes CLI is not on the remote PATH.'
}),
sshStatus: 'connected'
})
).toBe('Hermes CLI is not on the remote PATH.')
})
it('keeps concrete remote source errors when SSH status is unavailable to the caller', () => {
expect(
getExternalAutomationActionDisabledMessage({
manager: manager({
target: { type: 'ssh', connectionId: 'devbox' },
error: 'Hermes CLI is not on the remote PATH.'
})
})
).toBe('Hermes CLI is not on the remote PATH.')
})
it('explains disabled actions while another automation action is running', () => {
expect(
getExternalAutomationActionDisabledMessage({
manager: manager({ canManage: true }),
actionInProgress: true
})
).toBe('Another automation action is still running.')
})
})
@@ -1,4 +1,7 @@
import type { ExternalAutomationManager } from '../../../../shared/automations-types'
import type {
ExternalAutomationManager,
ExternalAutomationProvider
} from '../../../../shared/automations-types'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
export type ExternalAutomationSourceAvailability = {
@@ -74,3 +77,48 @@ export function getExternalAutomationSourceAvailability({
export function isSshConnectionBusy(status: SshConnectionStatus | undefined): boolean {
return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting'
}
export function getExternalAutomationActionDisabledMessage(args: {
manager: ExternalAutomationManager
providerLabel?: string
targetKindLabel?: string
sshStatus?: SshConnectionStatus
actionInProgress?: boolean
}): string | null {
if (args.actionInProgress) {
return 'Another automation action is still running.'
}
if (args.manager.canManage) {
return null
}
const providerLabel = args.providerLabel ?? getProviderLabel(args.manager.provider)
const targetKindLabel =
args.targetKindLabel ?? (args.manager.target.type === 'ssh' ? 'SSH host' : 'Local')
if (args.manager.target.type === 'ssh') {
if (isSshConnectionBusy(args.sshStatus)) {
return `Wait for this ${targetKindLabel.toLowerCase()} to finish connecting.`
}
if (args.manager.error && !isSshDisconnectedError(args.manager.error)) {
return args.manager.error
}
if (args.sshStatus !== 'connected') {
return `Connect this ${targetKindLabel.toLowerCase()} before managing ${providerLabel} automations.`
}
return (
args.manager.error ??
`${providerLabel} cannot manage automations on this ${targetKindLabel.toLowerCase()}.`
)
}
return (
args.manager.error ??
`${providerLabel} cannot manage automations on this ${targetKindLabel.toLowerCase()}.`
)
}
function getProviderLabel(provider: ExternalAutomationProvider): string {
return provider === 'hermes' ? 'Hermes' : 'OpenClaw'
}
function isSshDisconnectedError(message: string): boolean {
return /ssh target is not connected/i.test(message)
}