diff --git a/notes/host-context-implementation-checklist.md b/notes/host-context-implementation-checklist.md index 1b67b2fa40f..b91386b511d 100644 --- a/notes/host-context-implementation-checklist.md +++ b/notes/host-context-implementation-checklist.md @@ -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. diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index e121966e91b..5adebcaf971 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -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(null) const [selectedExternalRunPage, setSelectedExternalRunPage] = useState(null) + const runtimePreflightMountedRef = useRef(true) + const runtimePreflightRequestedHostIdsRef = useRef>(new Set()) + const [runtimePreflightStatusByHostId, setRuntimePreflightStatusByHostId] = useState< + ReadonlyMap + >(() => 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() + 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( + { 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() + 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' diff --git a/src/renderer/src/components/automations/automation-target-availability.test.ts b/src/renderer/src/components/automations/automation-target-availability.test.ts index 759192d3e63..315e7ec1803 100644 --- a/src/renderer/src/components/automations/automation-target-availability.test.ts +++ b/src/renderer/src/components/automations/automation-target-availability.test.ts @@ -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: { diff --git a/src/renderer/src/components/automations/automation-target-availability.ts b/src/renderer/src/components/automations/automation-target-availability.ts index 0319078de10..f51547f22b0 100644 --- a/src/renderer/src/components/automations/automation-target-availability.ts +++ b/src/renderer/src/components/automations/automation-target-availability.ts @@ -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: