Run automations after a repo's project identity changes (#7462)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-07-05 20:56:26 -07:00 committed by GitHub
parent 61bd98db6f
commit b2d079c80d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 222 additions and 6 deletions

View File

@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest'
import type { Store } from '../persistence'
import type { Automation } from '../../shared/automations-types'
import type { WorkspaceRunContext } from '../../shared/task-source-context'
import type { ProjectHostSetup, Repo } from '../../shared/types'
import { resolveAutomationRunTarget } from './run-target-resolution'
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: '/repo',
displayName: 'Repo',
badgeColor: '#fff',
addedAt: 1,
kind: 'git',
...overrides
}
}
function makeSetup(overrides: Partial<ProjectHostSetup> = {}): ProjectHostSetup {
return {
id: 'setup-1',
projectId: 'github:o/r',
hostId: 'local',
repoId: 'repo-1',
path: '/repo',
displayName: 'Repo',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function makeRunContext(overrides: Partial<WorkspaceRunContext> = {}): WorkspaceRunContext {
return {
kind: 'workspace-run',
projectId: 'repo:repo-1',
hostId: 'local',
projectHostSetupId: 'setup-1',
repoId: 'repo-1',
path: '/repo',
...overrides
}
}
function makeAutomation(runContext: WorkspaceRunContext): Automation {
return {
id: 'automation-1',
name: 'Nightly',
prompt: 'Run checks',
precheck: null,
agentId: 'codex',
projectId: 'repo-1',
executionTargetType: 'local',
executionTargetId: 'local',
schedulerOwner: 'local_host_service',
workspaceMode: 'new_per_run',
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: 1,
enabled: true,
nextRunAt: 2,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 1,
updatedAt: 1,
runContext
} as Automation
}
function makeStore(setups: ProjectHostSetup[], repos: Repo[]): Store {
return {
getProjectHostSetups: () => setups,
getRepo: (id: string) => repos.find((repo) => repo.id === id)
} as unknown as Store
}
describe('resolveAutomationRunTarget projectId drift', () => {
it('resolves when only the derived projectId tier differs (repo: snapshot vs github: setup)', () => {
const store = makeStore([makeSetup()], [makeRepo()])
const automation = makeAutomation(makeRunContext({ projectId: 'repo:repo-1' }))
const result = resolveAutomationRunTarget(store, automation)
expect(result).toMatchObject({ ok: true, cwd: '/repo' })
})
it('resolves when the snapshot is at the git: tier and the setup climbed to github:', () => {
const store = makeStore([makeSetup()], [makeRepo()])
const automation = makeAutomation(makeRunContext({ projectId: 'git:github.com/o/r' }))
const result = resolveAutomationRunTarget(store, automation)
expect(result).toMatchObject({ ok: true, cwd: '/repo' })
})
it('still blocks when the repoId no longer matches the setup', () => {
const store = makeStore([makeSetup({ repoId: 'repo-2' })], [makeRepo()])
const automation = makeAutomation(makeRunContext({ repoId: 'repo-1' }))
const result = resolveAutomationRunTarget(store, automation)
expect(result).toMatchObject({ ok: false })
})
it('still blocks when the hostId no longer matches the setup', () => {
// Guard returns at the setup hostId/repoId check before any repo lookup, so
// the repo's execution host is irrelevant here — assert the guard's own error.
const store = makeStore([makeSetup({ hostId: 'ssh:devbox' })], [makeRepo()])
const automation = makeAutomation(makeRunContext({ hostId: 'local' }))
const result = resolveAutomationRunTarget(store, automation)
expect(result).toMatchObject({
ok: false,
error: 'Automation run target no longer matches the selected project host setup.'
})
})
it("still blocks when the repo's execution host drifted off the automation host", () => {
// Setup/context hostId agree, but the repo itself now executes on another host —
// pins the repo-execution-host check that lives past the setup-match guard.
const store = makeStore([makeSetup()], [makeRepo({ executionHostId: 'ssh:devbox' })])
const automation = makeAutomation(makeRunContext())
const result = resolveAutomationRunTarget(store, automation)
expect(result).toMatchObject({
ok: false,
error: 'Repository is no longer attached to the selected automation host.'
})
})
it('still blocks when the path no longer matches the setup', () => {
const store = makeStore([makeSetup({ path: '/repo/new' })], [makeRepo({ path: '/repo/new' })])
const automation = makeAutomation(makeRunContext({ path: '/repo/old' }))
const result = resolveAutomationRunTarget(store, automation)
expect(result).toMatchObject({
ok: false,
error: 'Project path for the selected automation host has changed.'
})
})
})

View File

@ -64,11 +64,10 @@ export function resolveAutomationRunTarget(
error: `Project setup on the selected automation host is ${setup.setupState}.`
}
}
if (
setup.projectId !== context.projectId ||
setup.hostId !== context.hostId ||
setup.repoId !== context.repoId
) {
// Why: projectId is a derived identity that upgrades over time (repo:→git:→github:);
// matching on it strands automations created before their repo's identity resolved.
// Anchor on repoId/hostId/path instead — the durable, stable target identity.
if (setup.hostId !== context.hostId || setup.repoId !== context.repoId) {
return {
ok: false,
error: 'Automation run target no longer matches the selected project host setup.'

View File

@ -140,6 +140,72 @@ describe('automation target availability', () => {
).toBe('host-mismatch')
})
it('allows a run context whose derived projectId tier drifted but repo/host/path still match', () => {
expect(
getAutomationTargetAvailability({
automation: makeAutomation({
runContext: {
kind: 'workspace-run',
// Snapshotted at the repo: tier before the setup climbed to github:.
projectId: 'repo:repo-1',
hostId: 'local',
projectHostSetupId: 'setup-1',
repoId: 'repo-1',
path: '/repo'
}
}),
repo: makeRepo(),
workspace: makeWorkspace(),
projectHostSetups: [makeProjectHostSetup({ projectId: 'github:o/r' })],
sshConnectionStates: new Map()
})
).toEqual({ canRunNow: true, reason: 'available', message: null })
})
it('still blocks a run context whose repoId no longer matches despite projectId drift', () => {
expect(
getAutomationTargetAvailability({
automation: makeAutomation({
runContext: {
kind: 'workspace-run',
projectId: 'repo:repo-2',
hostId: 'local',
projectHostSetupId: 'setup-1',
repoId: 'repo-2',
path: '/repo'
}
}),
repo: makeRepo(),
workspace: makeWorkspace(),
projectHostSetups: [makeProjectHostSetup({ projectId: 'github:o/r', repoId: 'repo-2' })],
sshConnectionStates: new Map()
}).reason
).toBe('host-mismatch')
})
it('still blocks when the setup repoId drifts even though the live repo still matches', () => {
// Live repo matches runContext.repoId (repoMatchesContext passes), so this
// isolates the setup-side repoId clause that survives the projectId removal.
expect(
getAutomationTargetAvailability({
automation: makeAutomation({
runContext: {
kind: 'workspace-run',
projectId: 'repo:repo-1',
hostId: 'local',
projectHostSetupId: 'setup-1',
repoId: 'repo-1',
path: '/repo'
}
}),
repo: makeRepo(),
workspace: makeWorkspace(),
projectHostSetups: [makeProjectHostSetup({ projectId: 'github:o/r', repoId: 'repo-2' })],
sshConnectionStates: new Map()
}).reason
).toBe('host-mismatch')
})
it('allows remote-listed SSH automations whose repo is projected through a runtime server', () => {
expect(
getAutomationTargetAvailability({

View File

@ -96,8 +96,10 @@ export function getAutomationTargetAvailability({
`Project setup on the selected automation host is ${setup.setupState}.`
)
}
// Why: projectId is a derived identity that upgrades over time (repo:→git:→github:);
// matching on it strands automations created before their repo's identity resolved.
// Anchor on repoId/path/host instead — the durable, stable target identity.
const setupMatchesContext =
setup.projectId === automation.runContext.projectId &&
setup.repoId === automation.runContext.repoId &&
setup.path === automation.runContext.path &&
setupHostMatchesRunContext(setup.hostId, automation.runContext.hostId, automationHostTarget)