Validate automation source availability

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-13 02:06:08 -07:00
co-authored by Orca
parent 7a7534d319
commit dd57302742
4 changed files with 351 additions and 6 deletions
@@ -76,7 +76,7 @@ Done means: starting work from an external item carries the item’s source iden
- [x] Update automation dispatch/precheck code to validate selected run host availability and project host setup.
- [x] Verify automation live lookups do not use ambient settings: current Orca automations do not fetch issue/PR/task lists during dispatch, and branch/base-ref lookup routes through the selected repo host.
- [x] Update session reuse logic so an automation never resumes on the wrong host.
- [~] Add disabled states for disconnected run host, missing project host setup, missing source auth, unavailable source tool, and unsupported provider.
- [x] Add disabled states for disconnected run host, missing project host setup, missing source auth, unavailable source tool, and unsupported provider.
- [x] Add migration behavior for old automations that only know a repo id/path.
- [x] Update CLI/RPC automation APIs with v2 selectors for logical project, source context, and run host.
@@ -291,5 +291,6 @@ Done means: reviewers and future agents can understand the vision, implementatio
- [x] 2026-06-13: Routed agent draft-paste readiness probes and paste writes through the tab's worktree runtime owner when available, covering legacy remote PTY ids that do not embed their runtime owner. Verified with focused agent-paste tests, targeted oxlint, `git diff --check`, and full `pnpm run typecheck`.
- [x] 2026-06-13: Routed File Explorer directory reads, stat/import/create/move/duplicate operations, ignored-file checks, and background git/conflict polling through the selected worktree runtime owner instead of ambient focused settings. Verified with focused right-sidebar boundary/projection/polling/drag tests, targeted oxlint, `git diff --check`, and full `pnpm run typecheck`.
- [x] 2026-06-13: Fed runtime-owned GitHub/GitLab preflight into Tasks source availability by checking each available, task-source-capable runtime host with `preflight.check` and applying that host's `gh`/`glab` auth/tool status to only its own task sources. Verified with provider availability tests, TaskPage source-boundary tests, source-summary tests, targeted oxlint, `git diff --check`, and full `pnpm run typecheck`.
- [x] 2026-06-13: Fed saved automation source contexts into Run Now availability so internal Orca automations now disable before dispatch when the saved source host is unavailable, still checking capabilities, missing task-source support, missing GitHub/GitLab auth, or missing provider tooling. Verified with focused automation availability tests, provider availability tests, targeted oxlint, `git diff --check`, and full `pnpm run typecheck`.
- [~] 2026-06-13: Remaining source-error gap: unavailable hosts, version-skewed runtimes, and GitHub/GitLab missing auth/tooling are surfaced for desktop-owned and runtime-owned source hosts, but unsupported-provider still has no production emitter per source host.
- [~] 2026-06-13: Remaining Automation gap: run-host availability and missing project-host setup are handled for manual/internal/external actions, but `Automation.projectId` remains the legacy repo-id compatibility field and internal Orca automations do not yet validate saved `sourceContext` for missing provider auth/tooling or unsupported provider before dispatch.
- [~] 2026-06-13: Remaining Automation gap: run-host/source-host availability and missing project-host setup are handled for manual/internal/external actions, but `Automation.projectId` remains the legacy repo-id compatibility field.
@@ -36,6 +36,8 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useAppStore } from '@/store'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
import { cn } from '@/lib/utils'
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
import { getAgentCatalog } from '@/lib/agent-catalog'
@@ -51,6 +53,11 @@ import type {
AutomationRun,
AutomationUpdateInput
} from '../../../../shared/automations-types'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { PreflightStatus } from '../../../../preload/api-types'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
import type { Worktree } from '../../../../shared/types'
import { getWorktreePathBasenameFromId } from '../../../../shared/worktree-id'
import {
@@ -90,6 +97,11 @@ import { AutomationRunHistory } from './AutomationRunHistory'
import { getAutomationTemplates, type AutomationTemplate } from './automation-templates'
import { getAutomationTargetAvailability } from './automation-target-availability'
import { buildAutomationRunContextForRepo } from './automation-run-context'
import {
getRepoBackedProviderAvailability,
type RuntimeProviderPreflightStatus
} from '../task-source-provider-availability'
import type { TaskSourceHostAvailability } from '../task-source-context-summary'
import {
getExternalAutomationActionDisabledMessage,
getExternalAutomationSourceAvailability,
@@ -105,6 +117,7 @@ const AGENTS = getAgentCatalog().map((agent) => agent.id)
const DEFAULT_TIME = '09:00'
const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed'
type AutomationPaneTab = 'overview' | 'runs'
type RepoBackedAutomationSourceContext = TaskSourceContext & { provider: 'github' | 'gitlab' }
type ExternalAutomationListEntry =
| {
@@ -133,6 +146,46 @@ function getAutomationRunRepoId(automation: Automation): string {
return automation.runContext?.repoId ?? automation.projectId
}
function getRepoBackedAutomationSourceContext(
automation: Automation
): RepoBackedAutomationSourceContext | null {
const context = automation.sourceContext
return context?.provider === 'github' || context?.provider === 'gitlab'
? (context as RepoBackedAutomationSourceContext)
: null
}
function getRuntimeSourceHostAvailability(
context: TaskSourceContext,
runtimeStatusByEnvironmentId: ReadonlyMap<
string,
{ status: RuntimeStatus | null; checkedAt: number }
>
): TaskSourceHostAvailability | null {
const parsed = parseExecutionHostId(context.hostId)
if (parsed?.kind !== 'runtime') {
return null
}
const entry = runtimeStatusByEnvironmentId.get(parsed.environmentId)
if (!entry) {
return { hostId: context.hostId, reason: 'checking-task-source-capability' }
}
if (!entry.status) {
return { hostId: context.hostId, health: 'disconnected' }
}
if (entry.status.graphStatus !== 'ready') {
return { hostId: context.hostId, health: 'connecting' }
}
const capabilities = entry.status.capabilities
if (!capabilities) {
return { hostId: context.hostId, reason: 'checking-task-source-capability' }
}
if (!capabilities.includes(TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY)) {
return { hostId: context.hostId, reason: 'missing-task-source-capability' }
}
return null
}
function formatTimeInput(hour: number, minute: number): string {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}
@@ -278,6 +331,13 @@ export default function AutomationsPage(): React.JSX.Element {
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId)
const settings = useAppStore((s) => s.settings)
const preflightStatus = useAppStore((s) => s.preflightStatus)
const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked)
const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey)
const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus)
const expectedPreflightContextKey = useAppStore((s) =>
localPreflightContextKey(getLocalPreflightContext(s))
)
const selectedId = useAppStore((s) => s.selectedAutomationId)
const setSelectedId = useAppStore((s) => s.setSelectedAutomationId)
const repoMap = useRepoMap()
@@ -314,6 +374,11 @@ export default function AutomationsPage(): React.JSX.Element {
const [selectedExternalKey, setSelectedExternalKey] = useState<string | null>(null)
const [selectedExternalRunPage, setSelectedExternalRunPage] =
useState<SelectedExternalRunPage | null>(null)
const runtimePreflightMountedRef = useRef(true)
const runtimePreflightRequestedHostIdsRef = useRef<Set<TaskSourceContext['hostId']>>(new Set())
const [runtimePreflightStatusByHostId, setRuntimePreflightStatusByHostId] = useState<
ReadonlyMap<TaskSourceContext['hostId'], RuntimeProviderPreflightStatus>
>(() => new Map())
const selectAutomationId = useCallback(
(automationId: string | null): void => {
setSelectedAutomationRunPageId(null)
@@ -490,6 +555,128 @@ export default function AutomationsPage(): React.JSX.Element {
canRerunAutomationRun({ automation: selected, run: selectedAutomationRunPage })
const isSelectedAutomationRunPageRerunPending =
selectedAutomationRunPage !== null && rerunRunIdsInFlight.has(selectedAutomationRunPage.id)
const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey
const repoBackedAutomationSourceContexts = useMemo(
() =>
automations
.map((automation) => getRepoBackedAutomationSourceContext(automation))
.filter((context): context is RepoBackedAutomationSourceContext => context !== null),
[automations]
)
const runtimeAutomationSourceHostIds = useMemo(() => {
const hostIds = new Set<TaskSourceContext['hostId']>()
for (const context of repoBackedAutomationSourceContexts) {
const parsed = parseExecutionHostId(context.hostId)
if (parsed?.kind !== 'runtime') {
continue
}
const hostAvailability = getRuntimeSourceHostAvailability(
context,
runtimeStatusByEnvironmentId
)
if (hostAvailability) {
continue
}
hostIds.add(parsed.id)
}
return [...hostIds].sort()
}, [repoBackedAutomationSourceContexts, runtimeStatusByEnvironmentId])
useEffect(
() => () => {
runtimePreflightMountedRef.current = false
},
[]
)
useEffect(() => {
if (!preflightStatusCurrent || !preflightStatusChecked) {
void refreshPreflightStatus()
}
}, [preflightStatusChecked, preflightStatusCurrent, refreshPreflightStatus])
useEffect(() => {
const unrequestedHostIds = runtimeAutomationSourceHostIds.filter(
(hostId) => !runtimePreflightRequestedHostIdsRef.current.has(hostId)
)
if (unrequestedHostIds.length === 0) {
return
}
setRuntimePreflightStatusByHostId((current) => {
const next = new Map(current)
for (const hostId of unrequestedHostIds) {
next.set(hostId, { checked: false, status: null })
}
return next
})
for (const hostId of unrequestedHostIds) {
runtimePreflightRequestedHostIdsRef.current.add(hostId)
const parsed = parseExecutionHostId(hostId)
if (parsed?.kind !== 'runtime') {
continue
}
// Why: automation sources can be owned by a different remote server than
// the run target; provider auth/tooling must be checked on the source host.
void callRuntimeRpc<PreflightStatus>(
{ kind: 'environment', environmentId: parsed.environmentId },
'preflight.check',
undefined,
{ timeoutMs: 15_000 }
)
.then((status) => {
if (!runtimePreflightMountedRef.current) {
return
}
setRuntimePreflightStatusByHostId((current) => {
const next = new Map(current)
next.set(hostId, { checked: true, status })
return next
})
})
.catch(() => {
if (!runtimePreflightMountedRef.current) {
return
}
setRuntimePreflightStatusByHostId((current) => {
const next = new Map(current)
next.set(hostId, { checked: true, status: null })
return next
})
})
}
}, [runtimeAutomationSourceHostIds])
const automationSourceHostAvailabilityById = useMemo(() => {
const availabilityById = new Map<string, TaskSourceHostAvailability[]>()
for (const automation of automations) {
const context = getRepoBackedAutomationSourceContext(automation)
if (!context) {
continue
}
const hostAvailability = getRuntimeSourceHostAvailability(
context,
runtimeStatusByEnvironmentId
)
const providerAvailability = getRepoBackedProviderAvailability({
provider: context.provider,
contexts: [context],
preflightStatus,
preflightReady: preflightStatusCurrent && preflightStatusChecked,
runtimePreflightStatusByHostId
})
const availability = [
...(hostAvailability ? [hostAvailability] : []),
...providerAvailability
]
if (availability.length > 0) {
availabilityById.set(automation.id, availability)
}
}
return availabilityById
}, [
automations,
preflightStatus,
preflightStatusChecked,
preflightStatusCurrent,
runtimePreflightStatusByHostId,
runtimeStatusByEnvironmentId
])
const selectedRepo = selected ? (repoMap.get(getAutomationRunRepoId(selected)) ?? null) : null
const selectedWorktree =
selected && selected.workspaceId ? (worktreeMap.get(selected.workspaceId) ?? null) : null
@@ -500,7 +687,8 @@ export default function AutomationsPage(): React.JSX.Element {
workspace: selectedWorktree,
projectHostSetups,
sshConnectionStates,
runtimeStatusByEnvironmentId
runtimeStatusByEnvironmentId,
sourceHostAvailability: automationSourceHostAvailabilityById.get(selected.id)
})
: null
const canSaveDraft =
@@ -1253,7 +1441,8 @@ export default function AutomationsPage(): React.JSX.Element {
workspace,
projectHostSetups,
sshConnectionStates,
runtimeStatusByEnvironmentId
runtimeStatusByEnvironmentId,
sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id)
})
if (!availability.canRunNow) {
toast.error(availability.message)
@@ -1837,7 +2026,8 @@ export default function AutomationsPage(): React.JSX.Element {
workspace: automationWorktree,
projectHostSetups,
sshConnectionStates,
runtimeStatusByEnvironmentId
runtimeStatusByEnvironmentId,
sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id)
})
const workspaceLabel =
automation.workspaceMode === 'new_per_run'
@@ -256,6 +256,66 @@ describe('automation target availability', () => {
).toBe('ssh-connecting')
})
it('blocks manual runs when the saved source account needs provider auth', () => {
expect(
getAutomationTargetAvailability({
automation: makeAutomation({
sourceContext: {
kind: 'task-source',
provider: 'github',
projectId: 'github:stablyai/orca',
hostId: 'local',
repoId: 'repo-1',
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }
}
}),
repo: makeRepo(),
workspace: makeWorkspace(),
projectHostSetups: [],
sshConnectionStates: new Map(),
sourceHostAvailability: [{ hostId: 'local', reason: 'missing-provider-auth' }]
})
).toMatchObject({
canRunNow: false,
reason: 'source-auth-needed',
message: 'Connect the saved GitHub source account before running manually.'
})
})
it('blocks manual runs when the saved source host cannot support the provider', () => {
expect(
getAutomationTargetAvailability({
automation: makeAutomation({
sourceContext: {
kind: 'task-source',
provider: 'gitlab',
projectId: 'gitlab:stablyai/orca',
hostId: 'runtime:old-server',
repoId: 'repo-1',
providerIdentity: {
provider: 'gitlab',
projectId: 'stablyai/orca',
namespace: 'stablyai',
project: 'orca',
webUrl: 'https://gitlab.com/stablyai/orca'
}
}
}),
repo: makeRepo(),
workspace: makeWorkspace(),
projectHostSetups: [],
sshConnectionStates: new Map(),
sourceHostAvailability: [
{ hostId: 'runtime:old-server', reason: 'missing-task-source-capability' }
]
})
).toMatchObject({
canRunNow: false,
reason: 'source-provider-unsupported',
message: 'The saved GitLab source is not supported on this automation host.'
})
})
it('explains runtime-host automation availability before the unsupported manual-run fallback', () => {
const automation = makeAutomation({
runContext: {
@@ -9,8 +9,10 @@ import {
RUNTIME_PROTOCOL_VERSION
} from '../../../../shared/protocol-version'
import type { SshConnectionState } from '../../../../shared/ssh-types'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types'
import type { TaskSourceHostAvailability } from '../task-source-context-summary'
export type AutomationTargetAvailability =
| {
@@ -33,6 +35,10 @@ export type AutomationTargetAvailability =
| 'ssh-auth-needed'
| 'ssh-unavailable'
| 'ssh-connecting'
| 'source-auth-needed'
| 'source-tool-unavailable'
| 'source-provider-unsupported'
| 'source-host-unavailable'
message: string
}
@@ -46,6 +52,7 @@ type AutomationTargetAvailabilityArgs = {
string,
{ status: RuntimeStatus | null; checkedAt: number }
>
sourceHostAvailability?: readonly TaskSourceHostAvailability[]
}
export function getAutomationTargetAvailability({
@@ -54,7 +61,8 @@ export function getAutomationTargetAvailability({
workspace,
projectHostSetups,
sshConnectionStates,
runtimeStatusByEnvironmentId
runtimeStatusByEnvironmentId,
sourceHostAvailability
}: AutomationTargetAvailabilityArgs): AutomationTargetAvailability {
if (!repo) {
return unavailable('missing-project', 'The target project is no longer available.')
@@ -104,6 +112,14 @@ export function getAutomationTargetAvailability({
return unavailable('missing-workspace', 'The target workspace is no longer available.')
}
const sourceAvailability = getAutomationSourceAvailability(
automation.sourceContext,
sourceHostAvailability
)
if (sourceAvailability) {
return sourceAvailability
}
const sshTargetId = getAutomationSshTargetId(automation, repo)
if (!sshTargetId) {
return { canRunNow: true, reason: 'available', message: null }
@@ -126,6 +142,84 @@ export function getAutomationTargetAvailability({
}
}
function getAutomationSourceAvailability(
sourceContext: TaskSourceContext | null | undefined,
sourceHostAvailability: readonly TaskSourceHostAvailability[] | undefined
): AutomationTargetAvailability | null {
if (!sourceContext) {
return null
}
const availability = sourceHostAvailability?.find(
(entry) => entry.hostId === sourceContext.hostId
)
if (!availability) {
return null
}
const providerLabel = getAutomationSourceProviderLabel(sourceContext.provider)
switch (availability.reason) {
case 'missing-provider-auth':
return unavailable(
'source-auth-needed',
`Connect the saved ${providerLabel} source account before running manually.`
)
case 'unavailable-source-tool':
return unavailable(
'source-tool-unavailable',
`Install or configure the ${providerLabel} source tool before running manually.`
)
case 'unsupported-provider':
case 'missing-task-source-capability':
return unavailable(
'source-provider-unsupported',
`The saved ${providerLabel} source is not supported on this automation host.`
)
case 'checking-task-source-capability':
return unavailable(
'source-host-unavailable',
`Checking the saved ${providerLabel} source host before running manually.`
)
}
if (
availability.health === 'disconnected' ||
availability.health === 'blocked' ||
availability.health === 'error' ||
availability.status === 'disconnected' ||
availability.status === 'auth-failed' ||
availability.status === 'reconnection-failed' ||
availability.status === 'error'
) {
return unavailable(
'source-host-unavailable',
`Reconnect the saved ${providerLabel} source host before running manually.`
)
}
if (
availability.health === 'connecting' ||
availability.status === 'connecting' ||
availability.status === 'deploying-relay' ||
availability.status === 'reconnecting'
) {
return unavailable(
'source-host-unavailable',
`The saved ${providerLabel} source host is still connecting.`
)
}
return null
}
function getAutomationSourceProviderLabel(provider: TaskSourceContext['provider']): string {
switch (provider) {
case 'github':
return 'GitHub'
case 'gitlab':
return 'GitLab'
case 'linear':
return 'Linear'
case 'jira':
return 'Jira'
}
}
function getRuntimeAutomationAvailability(
environmentId: string,
runtimeStatusByEnvironmentId: