mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
Explain disabled external automation actions
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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') ? (
|
||||
|
||||
+71
-1
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user