Disable automation runs for stale host setup

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-12 23:50:55 -07:00
co-authored by Orca
parent 8d8a219785
commit 76cfbb7e2e
4 changed files with 113 additions and 2 deletions
@@ -267,3 +267,4 @@ Done means: reviewers and future agents can understand the vision, implementatio
- [x] 2026-06-12: Completed Settings ownership/account rows by adding a shared provider Host scope control with a Change Host action that opens Settings > Active Server from GitHub/GitLab/Linear/Jira account surfaces and GitHub/GitLab API budget panels. Existing ownership diagnostics still distinguish desktop-client versus remote-server credentials, and rate-limit panels show the budget Host scope. Verified with settings ownership, RuntimeEnvironmentsPane, provider account, source-control card, task-tracker card, Jira card, and rate-limit panel tests, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`.
- [x] 2026-06-12: Completed Tasks account/host metadata by showing repo-backed account labels in source summaries when GitHub/GitLab accounts differ by Host, while preserving host labels, provider identities, Linear workspace names, Jira site names, and unavailable-host status in labels/titles. Verified with source-summary and repo picker tests, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`.
- [x] 2026-06-12: Continued the automation project-id migration by changing CLI automation show output to foreground explicit `runContext` fields (`runProjectId`, `runHostId`, `projectHostSetupId`, `runRepoId`, `runPath`) and label the old repo id as `legacyRepoId`. The checklist row remains partial because the shared `Automation.projectId` compatibility field still exists. Verified with CLI format/index tests, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`.
- [x] 2026-06-12: Aligned Automations manual-run disabled states with the backend run-target resolver so saved run contexts now disable Run Now when the project-host setup is missing or not ready, instead of failing after dispatch. Verified with focused automation availability tests, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`.
@@ -497,6 +497,7 @@ export default function AutomationsPage(): React.JSX.Element {
automation: selected,
repo: selectedRepo,
workspace: selectedWorktree,
projectHostSetups,
sshConnectionStates
})
: null
@@ -1248,6 +1249,7 @@ export default function AutomationsPage(): React.JSX.Element {
automation,
repo,
workspace,
projectHostSetups,
sshConnectionStates
})
if (!availability.canRunNow) {
@@ -1830,6 +1832,7 @@ export default function AutomationsPage(): React.JSX.Element {
automation,
repo: automationRepo,
workspace: automationWorktree,
projectHostSetups,
sshConnectionStates
})
const workspaceLabel =
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { Automation } from '../../../../shared/automations-types'
import type { Repo, Worktree } from '../../../../shared/types'
import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types'
import { getAutomationTargetAvailability } from './automation-target-availability'
function makeAutomation(overrides: Partial<Automation> = {}): Automation {
@@ -53,6 +53,22 @@ function makeWorkspace(overrides: Partial<Worktree> = {}): Worktree {
} as Worktree
}
function makeProjectHostSetup(overrides: Partial<ProjectHostSetup> = {}): ProjectHostSetup {
return {
id: 'setup-1',
projectId: 'project-1',
hostId: 'local',
repoId: 'repo-1',
path: '/repo',
displayName: 'Repo',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1,
...overrides
}
}
describe('automation target availability', () => {
it('allows local automations with an available existing workspace', () => {
expect(
@@ -60,6 +76,7 @@ describe('automation target availability', () => {
automation: makeAutomation(),
repo: makeRepo(),
workspace: makeWorkspace(),
projectHostSetups: [],
sshConnectionStates: new Map()
})
).toEqual({ canRunNow: true, reason: 'available', message: null })
@@ -71,6 +88,7 @@ describe('automation target availability', () => {
automation: makeAutomation(),
repo: null,
workspace: makeWorkspace(),
projectHostSetups: [],
sshConnectionStates: new Map()
}).reason
).toBe('missing-project')
@@ -80,6 +98,7 @@ describe('automation target availability', () => {
automation: makeAutomation(),
repo: makeRepo(),
workspace: null,
projectHostSetups: [],
sshConnectionStates: new Map()
}).reason
).toBe('missing-workspace')
@@ -100,11 +119,48 @@ describe('automation target availability', () => {
}),
repo: makeRepo(),
workspace: makeWorkspace(),
projectHostSetups: [makeProjectHostSetup()],
sshConnectionStates: new Map()
}).reason
).toBe('host-mismatch')
})
it('blocks saved run contexts whose project host setup is missing or not ready', () => {
const automation = makeAutomation({
runContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: 'local',
projectHostSetupId: 'setup-1',
repoId: 'repo-1',
path: '/repo'
}
})
expect(
getAutomationTargetAvailability({
automation,
repo: makeRepo(),
workspace: makeWorkspace(),
projectHostSetups: [],
sshConnectionStates: new Map()
}).reason
).toBe('missing-project-host-setup')
expect(
getAutomationTargetAvailability({
automation,
repo: makeRepo(),
workspace: makeWorkspace(),
projectHostSetups: [makeProjectHostSetup({ setupState: 'error' })],
sshConnectionStates: new Map()
})
).toMatchObject({
reason: 'project-host-setup-not-ready',
message: 'Project setup on the selected automation host is error.'
})
})
it('requires SSH hosts to be connected before manual runs', () => {
const automation = makeAutomation({
executionTargetType: 'ssh',
@@ -125,6 +181,13 @@ describe('automation target availability', () => {
automation,
repo,
workspace: makeWorkspace(),
projectHostSetups: [
makeProjectHostSetup({
hostId: 'ssh:devbox',
connectionId: 'devbox',
executionHostId: 'ssh:devbox'
})
],
sshConnectionStates: new Map([['devbox', { status: 'connected' }]])
}).canRunNow
).toBe(true)
@@ -134,6 +197,13 @@ describe('automation target availability', () => {
automation,
repo,
workspace: makeWorkspace(),
projectHostSetups: [
makeProjectHostSetup({
hostId: 'ssh:devbox',
connectionId: 'devbox',
executionHostId: 'ssh:devbox'
})
],
sshConnectionStates: new Map([['devbox', { status: 'disconnected' }]])
}).reason
).toBe('ssh-unavailable')
@@ -143,6 +213,13 @@ describe('automation target availability', () => {
automation,
repo,
workspace: makeWorkspace(),
projectHostSetups: [
makeProjectHostSetup({
hostId: 'ssh:devbox',
connectionId: 'devbox',
executionHostId: 'ssh:devbox'
})
],
sshConnectionStates: new Map([['devbox', { status: 'auth-failed' }]])
}).reason
).toBe('ssh-auth-needed')
@@ -152,6 +229,13 @@ describe('automation target availability', () => {
automation,
repo,
workspace: makeWorkspace(),
projectHostSetups: [
makeProjectHostSetup({
hostId: 'ssh:devbox',
connectionId: 'devbox',
executionHostId: 'ssh:devbox'
})
],
sshConnectionStates: new Map([['devbox', { status: 'reconnecting' }]])
}).reason
).toBe('ssh-connecting')
@@ -1,7 +1,7 @@
import type { Automation } from '../../../../shared/automations-types'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host'
import type { SshConnectionState } from '../../../../shared/ssh-types'
import type { Repo, Worktree } from '../../../../shared/types'
import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types'
export type AutomationTargetAvailability =
| {
@@ -13,6 +13,8 @@ export type AutomationTargetAvailability =
canRunNow: false
reason:
| 'missing-project'
| 'missing-project-host-setup'
| 'project-host-setup-not-ready'
| 'missing-workspace'
| 'host-mismatch'
| 'unsupported-host'
@@ -26,6 +28,7 @@ type AutomationTargetAvailabilityArgs = {
automation: Automation
repo: Repo | null | undefined
workspace: Worktree | null | undefined
projectHostSetups: readonly ProjectHostSetup[]
sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>>
}
@@ -33,6 +36,7 @@ export function getAutomationTargetAvailability({
automation,
repo,
workspace,
projectHostSetups,
sshConnectionStates
}: AutomationTargetAvailabilityArgs): AutomationTargetAvailability {
if (!repo) {
@@ -46,7 +50,26 @@ export function getAutomationTargetAvailability({
'This automation targets a remote server that this client cannot run manually yet.'
)
}
const setup = projectHostSetups.find(
(candidate) => candidate.id === automation.runContext?.projectHostSetupId
)
if (!setup) {
return unavailable(
'missing-project-host-setup',
'Project is not set up on the selected automation host anymore.'
)
}
if (setup.setupState !== 'ready') {
return unavailable(
'project-host-setup-not-ready',
`Project setup on the selected automation host is ${setup.setupState}.`
)
}
if (
setup.projectId !== automation.runContext.projectId ||
setup.hostId !== automation.runContext.hostId ||
setup.repoId !== automation.runContext.repoId ||
setup.path !== automation.runContext.path ||
automation.runContext.repoId !== repo.id ||
automation.runContext.path !== repo.path ||
automation.runContext.hostId !== getRepoExecutionHostId(repo)